feat(web): show releases as one card with prose notes and the latest release in About
- commit
- e08572cdbcb35f78e9849a172ea0dd69ef5105b9
- author
- brice · · 2026-10-22 05:47 UTC
- committer
- brice · · 2026-10-22 08:51 UTC
- parent
- 5d69f2b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +518 -304
M internal/adapters/web/fragments.go +1 -1
| @@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 167 | 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 168 | 168 | switch ev := we.Event.(type) { | |
| 169 | 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | |
| 170 | - | if l.view == "releases" || l.view == "release" { | |
| 170 | + | if l.view == "releases" || l.view == "release" || l.view == "summary" { | |
| 171 | 171 | return gap, nil // notes, assets, latest or visibility changed: reload | |
| 172 | 172 | } | |
| 173 | 173 | return nil, nil | |
| @@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||||
| 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | ||
| 168 | switch ev := we.Event.(type) { | 168 | switch ev := we.Event.(type) { | ||
| 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | ||
| 170 | - | if l.view == "releases" || l.view == "release" { | 170 | + | if l.view == "releases" || l.view == "release" || l.view == "summary" { |
| 171 | return gap, nil // notes, assets, latest or visibility changed: reload | 171 | return gap, nil // notes, assets, latest or visibility changed: reload | ||
| 172 | } | 172 | } | ||
| 173 | return nil, nil | 173 | return nil, nil | ||
M internal/adapters/web/fragments_test.go +3 -3
| @@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) { | |||
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | // Release lifecycle events invalidate the release pages without putting notes | |
| 105 | - | // or asset metadata in SSE payloads. Other views need no release fragments. | |
| 104 | + | // Release lifecycle events invalidate release pages and the summary's release | |
| 105 | + | // fact without putting notes or asset metadata in SSE payloads. | |
| 106 | 106 | func TestReleaseLiveUpdates(t *testing.T) { | |
| 107 | 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | |
| 108 | 108 | we := ports.WatchEvent{Event: ev} | |
| @@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) { | |||
| 111 | 111 | st := &liveStream{repo: "proj", view: view} | |
| 112 | 112 | got, err := st.render(t.Context(), we) | |
| 113 | 113 | require.NoError(t, err) | |
| 114 | - | if view == "release" || view == "releases" { | |
| 114 | + | if view == "release" || view == "releases" || view == "summary" { | |
| 115 | 115 | assert.Equal(t, gap, got) | |
| 116 | 116 | } else { | |
| 117 | 117 | assert.Empty(t, got) | |
| @@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) { | |||||
| 101 | } | 101 | } | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | // Release lifecycle events invalidate the release pages without putting notes | 104 | + | // Release lifecycle events invalidate release pages and the summary's release |
| 105 | - | // or asset metadata in SSE payloads. Other views need no release fragments. | 105 | + | // fact without putting notes or asset metadata in SSE payloads. |
| 106 | func TestReleaseLiveUpdates(t *testing.T) { | 106 | func TestReleaseLiveUpdates(t *testing.T) { | ||
| 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | ||
| 108 | we := ports.WatchEvent{Event: ev} | 108 | we := ports.WatchEvent{Event: ev} | ||
| @@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) { | |||||
| 111 | st := &liveStream{repo: "proj", view: view} | 111 | st := &liveStream{repo: "proj", view: view} | ||
| 112 | got, err := st.render(t.Context(), we) | 112 | got, err := st.render(t.Context(), we) | ||
| 113 | require.NoError(t, err) | 113 | require.NoError(t, err) | ||
| 114 | - | if view == "release" || view == "releases" { | 114 | + | if view == "release" || view == "releases" || view == "summary" { |
| 115 | assert.Equal(t, gap, got) | 115 | assert.Equal(t, gap, got) | ||
| 116 | } else { | 116 | } else { | ||
| 117 | assert.Empty(t, got) | 117 | assert.Empty(t, got) | ||
M internal/adapters/web/pages.templ +8 -0
| @@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) { | |||
| 115 | 115 | if p.Latest != nil { | |
| 116 | 116 | @latestTagFact(*p.Latest) | |
| 117 | 117 | } | |
| 118 | + | if r := p.Release; r != nil { | |
| 119 | + | <dt>release</dt> | |
| 120 | + | <dd> | |
| 121 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " } | |
| 122 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " } | |
| 123 | + | <a href={ templ.SafeURL(r.AllHref) }>all releases</a> | |
| 124 | + | </dd> | |
| 125 | + | } | |
| 118 | 126 | </dl> | |
| 119 | 127 | </section> | |
| 120 | 128 | if len(p.Recent) > 0 { | |
| @@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) { | |||||
| 115 | if p.Latest != nil { | 115 | if p.Latest != nil { | ||
| 116 | @latestTagFact(*p.Latest) | 116 | @latestTagFact(*p.Latest) | ||
| 117 | } | 117 | } | ||
| 118 | + | if r := p.Release; r != nil { | |||
| 119 | + | <dt>release</dt> | |||
| 120 | + | <dd> | |||
| 121 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " } | |||
| 122 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " } | |||
| 123 | + | <a href={ templ.SafeURL(r.AllHref) }>all releases</a> | |||
| 124 | + | </dd> | |||
| 125 | + | } | |||
| 118 | </dl> | 126 | </dl> | ||
| 119 | </section> | 127 | </section> | ||
| 120 | if len(p.Recent) > 0 { | 128 | if len(p.Recent) > 0 { | ||
M internal/adapters/web/pages_templ.go +371 -261 generated file: collapsed
M internal/adapters/web/releases.templ +16 -9
| @@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) { | |||
| 40 | 40 | ||
| 41 | 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | |
| 42 | 42 | @Shell(l, nil) { | |
| 43 | - | <h1>{ p.Title }</h1> | |
| 44 | - | <p> | |
| 45 | - | <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow) | |
| 46 | - | if p.SHA != "" { | |
| 47 | - | <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a> | |
| 48 | - | } | |
| 49 | - | <span class="dim">{ p.Published }</span> | |
| 50 | - | </p> | |
| 51 | - | <div class="md">@templ.Raw(string(p.Notes))</div> | |
| 43 | + | <section class="mr-card"> | |
| 44 | + | <header class="show mr-h"> | |
| 45 | + | <h1>{ p.Title }</h1> | |
| 46 | + | <dl class="kv show-kv"> | |
| 47 | + | <dt>tag</dt> | |
| 48 | + | <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd> | |
| 49 | + | if p.SHA != "" { | |
| 50 | + | <dt>commit</dt> | |
| 51 | + | <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd> | |
| 52 | + | } | |
| 53 | + | <dt>published</dt> | |
| 54 | + | <dd>{ p.Published }</dd> | |
| 55 | + | </dl> | |
| 56 | + | </header> | |
| 57 | + | <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article> | |
| 58 | + | </section> | |
| 52 | 59 | <h2>Assets</h2> | |
| 53 | 60 | if len(p.Assets) == 0 { | |
| 54 | 61 | <p class="dim">no assets.</p> | |
| @@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) { | |||||
| 40 | 40 | ||||
| 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | ||
| 42 | @Shell(l, nil) { | 42 | @Shell(l, nil) { | ||
| 43 | - | <h1>{ p.Title }</h1> | 43 | + | <section class="mr-card"> |
| 44 | - | <p> | 44 | + | <header class="show mr-h"> |
| 45 | - | <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow) | 45 | + | <h1>{ p.Title }</h1> |
| 46 | - | if p.SHA != "" { | 46 | + | <dl class="kv show-kv"> |
| 47 | - | <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a> | 47 | + | <dt>tag</dt> |
| 48 | - | } | 48 | + | <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd> |
| 49 | - | <span class="dim">{ p.Published }</span> | 49 | + | if p.SHA != "" { |
| 50 | - | </p> | 50 | + | <dt>commit</dt> |
| 51 | - | <div class="md">@templ.Raw(string(p.Notes))</div> | 51 | + | <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd> |
| 52 | + | } | |||
| 53 | + | <dt>published</dt> | |||
| 54 | + | <dd>{ p.Published }</dd> | |||
| 55 | + | </dl> | |||
| 56 | + | </header> | |||
| 57 | + | <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article> | |||
| 58 | + | </section> | |||
| 52 | <h2>Assets</h2> | 59 | <h2>Assets</h2> | ||
| 53 | if len(p.Assets) == 0 { | 60 | if len(p.Assets) == 0 { | ||
| 54 | <p class="dim">no assets.</p> | 61 | <p class="dim">no assets.</p> | ||
M internal/adapters/web/releases_templ.go +32 -28 generated file: collapsed
M internal/adapters/web/releases_test.go +10 -1
| @@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture { | |||
| 38 | 38 | t.Helper() | |
| 39 | 39 | repo := repoName(t, "proj") | |
| 40 | 40 | f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"} | |
| 41 | - | f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} | |
| 41 | + | f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} | |
| 42 | 42 | f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true} | |
| 43 | 43 | f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)} | |
| 44 | 44 | f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | |
| @@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) { | |||
| 137 | 137 | for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} { | |
| 138 | 138 | assert.Contains(t, body, text) | |
| 139 | 139 | } | |
| 140 | + | notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2) | |
| 141 | + | require.Len(t, notes, 2) | |
| 142 | + | notesBody := strings.SplitN(notes[1], "</article>", 2)[0] | |
| 143 | + | assert.Contains(t, notesBody, "<pre") | |
| 144 | + | assert.Contains(t, notesBody, "<code") | |
| 145 | + | assert.Contains(t, notesBody, "curl") | |
| 146 | + | assert.Contains(t, notesBody, "</code></pre>") | |
| 147 | + | assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`) | |
| 148 | + | assert.Contains(t, body, `<section class="mr-card">`) | |
| 140 | 149 | assert.NotContains(t, body, "<script>alert") | |
| 141 | 150 | assert.NotContains(t, body, `href="javascript:`) | |
| 142 | 151 | assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`) | |
| @@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture { | |||||
| 38 | t.Helper() | 38 | t.Helper() | ||
| 39 | repo := repoName(t, "proj") | 39 | repo := repoName(t, "proj") | ||
| 40 | f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"} | 40 | f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"} | ||
| 41 | - | f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} | 41 | + | f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} |
| 42 | f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true} | 42 | f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true} | ||
| 43 | f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)} | 43 | f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)} | ||
| 44 | f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | 44 | f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | ||
| @@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) { | |||||
| 137 | for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} { | 137 | for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} { | ||
| 138 | assert.Contains(t, body, text) | 138 | assert.Contains(t, body, text) | ||
| 139 | } | 139 | } | ||
| 140 | + | notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2) | |||
| 141 | + | require.Len(t, notes, 2) | |||
| 142 | + | notesBody := strings.SplitN(notes[1], "</article>", 2)[0] | |||
| 143 | + | assert.Contains(t, notesBody, "<pre") | |||
| 144 | + | assert.Contains(t, notesBody, "<code") | |||
| 145 | + | assert.Contains(t, notesBody, "curl") | |||
| 146 | + | assert.Contains(t, notesBody, "</code></pre>") | |||
| 147 | + | assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`) | |||
| 148 | + | assert.Contains(t, body, `<section class="mr-card">`) | |||
| 140 | assert.NotContains(t, body, "<script>alert") | 149 | assert.NotContains(t, body, "<script>alert") | ||
| 141 | assert.NotContains(t, body, `href="javascript:`) | 150 | assert.NotContains(t, body, `href="javascript:`) | ||
| 142 | assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`) | 151 | assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`) | ||
M internal/adapters/web/summary.go +21 -0
| @@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 68 | 68 | ||
| 69 | 69 | p.Facts = s.facts(d, now) | |
| 70 | 70 | p.Latest = s.latest(ctx, d) | |
| 71 | + | p.Release = s.summaryRelease(ctx, d.repo.Name, now) | |
| 71 | 72 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | |
| 72 | 73 | ||
| 73 | 74 | if rd, err := s.readme(ctx, d, ref); err != nil { | |
| @@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no | |||
| 272 | 273 | } | |
| 273 | 274 | return a, nil | |
| 274 | 275 | } | |
| 276 | + | ||
| 277 | + | // summaryRelease uses the same Latest selection as the releases page. A | |
| 278 | + | // missing service or an unavailable release must not prevent repository browsing. | |
| 279 | + | func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease { | |
| 280 | + | if s.cfg.Releases == nil { | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true}) | |
| 284 | + | if err != nil { | |
| 285 | + | if !errors.Is(err, domain.ErrNotFound) { | |
| 286 | + | log := zerowrap.FromCtx(ctx) | |
| 287 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release") | |
| 288 | + | } | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | if rel.Draft || rel.PublishedAt.IsZero() { | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())} | |
| 295 | + | } | |
| @@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 68 | 68 | ||||
| 69 | p.Facts = s.facts(d, now) | 69 | p.Facts = s.facts(d, now) | ||
| 70 | p.Latest = s.latest(ctx, d) | 70 | p.Latest = s.latest(ctx, d) | ||
| 71 | + | p.Release = s.summaryRelease(ctx, d.repo.Name, now) | |||
| 71 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | 72 | p.Actions = s.recentActions(ctx, d.repo.Name, now) | ||
| 72 | 73 | ||||
| 73 | if rd, err := s.readme(ctx, d, ref); err != nil { | 74 | if rd, err := s.readme(ctx, d, ref); err != nil { | ||
| @@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no | |||||
| 272 | } | 273 | } | ||
| 273 | return a, nil | 274 | return a, nil | ||
| 274 | } | 275 | } | ||
| 276 | + | ||||
| 277 | + | // summaryRelease uses the same Latest selection as the releases page. A | |||
| 278 | + | // missing service or an unavailable release must not prevent repository browsing. | |||
| 279 | + | func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease { | |||
| 280 | + | if s.cfg.Releases == nil { | |||
| 281 | + | return nil | |||
| 282 | + | } | |||
| 283 | + | rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true}) | |||
| 284 | + | if err != nil { | |||
| 285 | + | if !errors.Is(err, domain.ErrNotFound) { | |||
| 286 | + | log := zerowrap.FromCtx(ctx) | |||
| 287 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release") | |||
| 288 | + | } | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
| 291 | + | if rel.Draft || rel.PublishedAt.IsZero() { | |||
| 292 | + | return nil | |||
| 293 | + | } | |||
| 294 | + | return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())} | |||
| 295 | + | } | |||
A internal/adapters/web/summary_releases_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestSummaryRelease(t *testing.T) { | |
| 20 | + | repo := repoName(t, "proj") | |
| 21 | + | for _, tc := range []struct { | |
| 22 | + | name string | |
| 23 | + | release domain.Release | |
| 24 | + | err error | |
| 25 | + | visible bool | |
| 26 | + | }{ | |
| 27 | + | {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true}, | |
| 28 | + | {name: "none", err: domain.ErrNotFound}, | |
| 29 | + | {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}}, | |
| 30 | + | } { | |
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |
| 32 | + | svc := mocks.NewReleaseService(t) | |
| 33 | + | svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once() | |
| 34 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc}) | |
| 35 | + | rel := srv.summaryRelease(t.Context(), repo, fixedNow) | |
| 36 | + | out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ") | |
| 37 | + | if !tc.visible { | |
| 38 | + | assert.Nil(t, rel) | |
| 39 | + | assert.NotContains(t, out, "<dt>release</dt>") | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | require.NotNil(t, rel) | |
| 43 | + | assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`) | |
| 44 | + | assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`) | |
| 45 | + | assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`) | |
| 46 | + | assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out) | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestSummaryRelease(t *testing.T) { | |||
| 20 | + | repo := repoName(t, "proj") | |||
| 21 | + | for _, tc := range []struct { | |||
| 22 | + | name string | |||
| 23 | + | release domain.Release | |||
| 24 | + | err error | |||
| 25 | + | visible bool | |||
| 26 | + | }{ | |||
| 27 | + | {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true}, | |||
| 28 | + | {name: "none", err: domain.ErrNotFound}, | |||
| 29 | + | {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}}, | |||
| 30 | + | } { | |||
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 32 | + | svc := mocks.NewReleaseService(t) | |||
| 33 | + | svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once() | |||
| 34 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc}) | |||
| 35 | + | rel := srv.summaryRelease(t.Context(), repo, fixedNow) | |||
| 36 | + | out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ") | |||
| 37 | + | if !tc.visible { | |||
| 38 | + | assert.Nil(t, rel) | |||
| 39 | + | assert.NotContains(t, out, "<dt>release</dt>") | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | require.NotNil(t, rel) | |||
| 43 | + | assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`) | |||
| 44 | + | assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`) | |||
| 45 | + | assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`) | |||
| 46 | + | assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out) | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
M internal/adapters/web/view.go +7 -1
| @@ -211,12 +211,18 @@ type SummaryPage struct { | |||
| 211 | 211 | Outline []Heading | |
| 212 | 212 | AllHref string | |
| 213 | 213 | // Latest is the newest tag, nil when the repository has none. | |
| 214 | - | Latest *LatestTag | |
| 214 | + | Latest *LatestTag | |
| 215 | + | Release *LatestRelease | |
| 215 | 216 | // Actions is the recent runs of the rail, nil when the viewer cannot read | |
| 216 | 217 | // CI (or the server runs none). | |
| 217 | 218 | Actions *RecentActions | |
| 218 | 219 | } | |
| 219 | 220 | ||
| 221 | + | // LatestRelease is the published release selected as Latest by the release service. | |
| 222 | + | type LatestRelease struct { | |
| 223 | + | Tag, Href, When, WhenAt, AllHref string | |
| 224 | + | } | |
| 225 | + | ||
| 220 | 226 | // RecentActions is the "Recent actions" block of the summary rail: the newest | |
| 221 | 227 | // runs. With no run the block is in the page but hidden, so that the first | |
| 222 | 228 | // run reaches it live. | |
| @@ -211,12 +211,18 @@ type SummaryPage struct { | |||||
| 211 | Outline []Heading | 211 | Outline []Heading | ||
| 212 | AllHref string | 212 | AllHref string | ||
| 213 | // Latest is the newest tag, nil when the repository has none. | 213 | // Latest is the newest tag, nil when the repository has none. | ||
| 214 | - | Latest *LatestTag | 214 | + | Latest *LatestTag |
| 215 | + | Release *LatestRelease | |||
| 215 | // Actions is the recent runs of the rail, nil when the viewer cannot read | 216 | // Actions is the recent runs of the rail, nil when the viewer cannot read | ||
| 216 | // CI (or the server runs none). | 217 | // CI (or the server runs none). | ||
| 217 | Actions *RecentActions | 218 | Actions *RecentActions | ||
| 218 | } | 219 | } | ||
| 219 | 220 | ||||
| 221 | + | // LatestRelease is the published release selected as Latest by the release service. | |||
| 222 | + | type LatestRelease struct { | |||
| 223 | + | Tag, Href, When, WhenAt, AllHref string | |||
| 224 | + | } | |||
| 225 | + | ||||
| 220 | // RecentActions is the "Recent actions" block of the summary rail: the newest | 226 | // RecentActions is the "Recent actions" block of the summary rail: the newest | ||
| 221 | // runs. With no run the block is in the page but hidden, so that the first | 227 | // runs. With no run the block is in the page but hidden, so that the first | ||
| 222 | // run reaches it live. | 228 | // run reaches it live. | ||