feat(mirror): warn about failing mirrors on git push and show a badge on the repo page
- commit
- 475ebb670b09953ca4b84b98a0a9c24981098c7a
- author
- brice · · 2026-10-20 05:54 UTC
- committer
- brice · · 2026-10-20 10:02 UTC
- parent
- ed419c7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +501 -272
M internal/adapters/githttp/hooks.go +30 -3
| @@ -37,8 +37,9 @@ type hookEntry struct { | |||
| 37 | 37 | // HookServer registers the per-request hook tokens and serves the Unix | |
| 38 | 38 | // socket the hooks call back on. It implements HookTokens. | |
| 39 | 39 | type HookServer struct { | |
| 40 | - | path string | |
| 41 | - | hooks ports.PushHookService | |
| 40 | + | path string | |
| 41 | + | hooks ports.PushHookService | |
| 42 | + | mirrors ports.MirrorWarner | |
| 42 | 43 | ||
| 43 | 44 | mu sync.RWMutex | |
| 44 | 45 | tokens map[string]hookEntry | |
| @@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer { | |||
| 56 | 57 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 60 | + | // WithMirrorWarner enables best-effort mirror warnings. Configure before serving. | |
| 61 | + | func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer { | |
| 62 | + | s.mirrors = w | |
| 63 | + | return s | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // mirrorWarning bounds even a source that does not honor context cancellation. | |
| 67 | + | func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string { | |
| 68 | + | if s.mirrors == nil { | |
| 69 | + | return "" | |
| 70 | + | } | |
| 71 | + | ctx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 72 | + | defer cancel() | |
| 73 | + | result := make(chan string, 1) | |
| 74 | + | go func() { result <- s.mirrors.Warning(ctx, repo) }() | |
| 75 | + | select { | |
| 76 | + | case warning := <-result: | |
| 77 | + | if ctx.Err() != nil || warning == "" { | |
| 78 | + | return "" | |
| 79 | + | } | |
| 80 | + | return warning + "\n→ ermite mirror status " + repo.String() | |
| 81 | + | case <-ctx.Done(): | |
| 82 | + | return "" | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 59 | 86 | // Socket returns the path of the socket. | |
| 60 | 87 | func (s *HookServer) Socket() string { return s.path } | |
| 61 | 88 | ||
| @@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |||
| 229 | 256 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | |
| 230 | 257 | return | |
| 231 | 258 | } | |
| 232 | - | s.reply(ctx, conn, HookReply{OK: true}) | |
| 259 | + | s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)}) | |
| 233 | 260 | } | |
| 234 | 261 | } | |
| 235 | 262 | ||
| @@ -37,8 +37,9 @@ type hookEntry struct { | |||||
| 37 | // HookServer registers the per-request hook tokens and serves the Unix | 37 | // HookServer registers the per-request hook tokens and serves the Unix | ||
| 38 | // socket the hooks call back on. It implements HookTokens. | 38 | // socket the hooks call back on. It implements HookTokens. | ||
| 39 | type HookServer struct { | 39 | type HookServer struct { | ||
| 40 | - | path string | 40 | + | path string |
| 41 | - | hooks ports.PushHookService | 41 | + | hooks ports.PushHookService |
| 42 | + | mirrors ports.MirrorWarner | |||
| 42 | 43 | ||||
| 43 | mu sync.RWMutex | 44 | mu sync.RWMutex | ||
| 44 | tokens map[string]hookEntry | 45 | tokens map[string]hookEntry | ||
| @@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer { | |||||
| 56 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | 57 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 60 | + | // WithMirrorWarner enables best-effort mirror warnings. Configure before serving. | |||
| 61 | + | func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer { | |||
| 62 | + | s.mirrors = w | |||
| 63 | + | return s | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // mirrorWarning bounds even a source that does not honor context cancellation. | |||
| 67 | + | func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string { | |||
| 68 | + | if s.mirrors == nil { | |||
| 69 | + | return "" | |||
| 70 | + | } | |||
| 71 | + | ctx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 72 | + | defer cancel() | |||
| 73 | + | result := make(chan string, 1) | |||
| 74 | + | go func() { result <- s.mirrors.Warning(ctx, repo) }() | |||
| 75 | + | select { | |||
| 76 | + | case warning := <-result: | |||
| 77 | + | if ctx.Err() != nil || warning == "" { | |||
| 78 | + | return "" | |||
| 79 | + | } | |||
| 80 | + | return warning + "\n→ ermite mirror status " + repo.String() | |||
| 81 | + | case <-ctx.Done(): | |||
| 82 | + | return "" | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 59 | // Socket returns the path of the socket. | 86 | // Socket returns the path of the socket. | ||
| 60 | func (s *HookServer) Socket() string { return s.path } | 87 | func (s *HookServer) Socket() string { return s.path } | ||
| 61 | 88 | ||||
| @@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |||||
| 229 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | 256 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | ||
| 230 | return | 257 | return | ||
| 231 | } | 258 | } | ||
| 232 | - | s.reply(ctx, conn, HookReply{OK: true}) | 259 | + | s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)}) |
| 233 | } | 260 | } | ||
| 234 | } | 261 | } | ||
| 235 | 262 | ||||
M internal/adapters/githttp/hooks_test.go +46 -0
| @@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | |||
| 412 | 412 | require.NoError(t, err) | |
| 413 | 413 | assert.Equal(t, "line\n", string(in), "stdin reaches the binary") | |
| 414 | 414 | } | |
| 415 | + | ||
| 416 | + | func TestMirrorPostReceiveWarning(t *testing.T) { | |
| 417 | + | for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} { | |
| 418 | + | t.Run(warning, func(t *testing.T) { | |
| 419 | + | hooks := mocks.NewPushHookService(t) | |
| 420 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
| 421 | + | srv, sock := startHooks(t, hooks) | |
| 422 | + | w := mocks.NewMirrorWarner(t) | |
| 423 | + | w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning) | |
| 424 | + | srv.WithMirrorWarner(w) | |
| 425 | + | token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |
| 426 | + | defer release() | |
| 427 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |
| 428 | + | assert.True(t, reply.OK) | |
| 429 | + | if warning == "" { | |
| 430 | + | assert.Empty(t, reply.Message) | |
| 431 | + | } else { | |
| 432 | + | assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message) | |
| 433 | + | } | |
| 434 | + | }) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestMirrorPostReceiveTimeout(t *testing.T) { | |
| 439 | + | hooks := mocks.NewPushHookService(t) | |
| 440 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
| 441 | + | srv, sock := startHooks(t, hooks) | |
| 442 | + | w := mocks.NewMirrorWarner(t) | |
| 443 | + | release := make(chan struct{}) | |
| 444 | + | defer close(release) | |
| 445 | + | w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string { | |
| 446 | + | deadline, ok := ctx.Deadline() | |
| 447 | + | assert.True(t, ok) | |
| 448 | + | assert.LessOrEqual(t, time.Until(deadline), 2*time.Second) | |
| 449 | + | <-release // deliberately ignores cancellation | |
| 450 | + | return "late warning" | |
| 451 | + | }) | |
| 452 | + | srv.WithMirrorWarner(w) | |
| 453 | + | token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |
| 454 | + | defer revoke() | |
| 455 | + | start := time.Now() | |
| 456 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |
| 457 | + | assert.True(t, reply.OK) | |
| 458 | + | assert.Empty(t, reply.Message) | |
| 459 | + | assert.Less(t, time.Since(start), 2500*time.Millisecond) | |
| 460 | + | } | |
| @@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | |||||
| 412 | require.NoError(t, err) | 412 | require.NoError(t, err) | ||
| 413 | assert.Equal(t, "line\n", string(in), "stdin reaches the binary") | 413 | assert.Equal(t, "line\n", string(in), "stdin reaches the binary") | ||
| 414 | } | 414 | } | ||
| 415 | + | ||||
| 416 | + | func TestMirrorPostReceiveWarning(t *testing.T) { | |||
| 417 | + | for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} { | |||
| 418 | + | t.Run(warning, func(t *testing.T) { | |||
| 419 | + | hooks := mocks.NewPushHookService(t) | |||
| 420 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |||
| 421 | + | srv, sock := startHooks(t, hooks) | |||
| 422 | + | w := mocks.NewMirrorWarner(t) | |||
| 423 | + | w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning) | |||
| 424 | + | srv.WithMirrorWarner(w) | |||
| 425 | + | token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |||
| 426 | + | defer release() | |||
| 427 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |||
| 428 | + | assert.True(t, reply.OK) | |||
| 429 | + | if warning == "" { | |||
| 430 | + | assert.Empty(t, reply.Message) | |||
| 431 | + | } else { | |||
| 432 | + | assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message) | |||
| 433 | + | } | |||
| 434 | + | }) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func TestMirrorPostReceiveTimeout(t *testing.T) { | |||
| 439 | + | hooks := mocks.NewPushHookService(t) | |||
| 440 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |||
| 441 | + | srv, sock := startHooks(t, hooks) | |||
| 442 | + | w := mocks.NewMirrorWarner(t) | |||
| 443 | + | release := make(chan struct{}) | |||
| 444 | + | defer close(release) | |||
| 445 | + | w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string { | |||
| 446 | + | deadline, ok := ctx.Deadline() | |||
| 447 | + | assert.True(t, ok) | |||
| 448 | + | assert.LessOrEqual(t, time.Until(deadline), 2*time.Second) | |||
| 449 | + | <-release // deliberately ignores cancellation | |||
| 450 | + | return "late warning" | |||
| 451 | + | }) | |||
| 452 | + | srv.WithMirrorWarner(w) | |||
| 453 | + | token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |||
| 454 | + | defer revoke() | |||
| 455 | + | start := time.Now() | |||
| 456 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |||
| 457 | + | assert.True(t, reply.OK) | |||
| 458 | + | assert.Empty(t, reply.Message) | |||
| 459 | + | assert.Less(t, time.Since(start), 2500*time.Millisecond) | |||
| 460 | + | } | |||
A internal/adapters/web/mirror.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | "bnema.dev/ermite/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // MirrorBadge is an administrative diagnostic in the summary rail. | |
| 13 | + | type MirrorBadge struct{ State, Reason, Command string } | |
| 14 | + | ||
| 15 | + | // WithMirrorWarner enables mirror badges. Configure before serving requests. | |
| 16 | + | func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server { | |
| 17 | + | s.mirrors = w | |
| 18 | + | return s | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // mirrorBadge selects presentation only; the source authorizes the lookup and reason. | |
| 22 | + | // | |
| 23 | + | //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator | |
| 24 | + | func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge { | |
| 25 | + | p := principal(ctx) | |
| 26 | + | if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) { | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | state, reason := s.mirrors.Badge(ctx, p, repo) | |
| 30 | + | if state != domain.MirrorFailing && state != domain.MirrorPaused { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()} | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/domain" | |||
| 9 | + | "bnema.dev/ermite/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // MirrorBadge is an administrative diagnostic in the summary rail. | |||
| 13 | + | type MirrorBadge struct{ State, Reason, Command string } | |||
| 14 | + | ||||
| 15 | + | // WithMirrorWarner enables mirror badges. Configure before serving requests. | |||
| 16 | + | func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server { | |||
| 17 | + | s.mirrors = w | |||
| 18 | + | return s | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // mirrorBadge selects presentation only; the source authorizes the lookup and reason. | |||
| 22 | + | // | |||
| 23 | + | //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator | |||
| 24 | + | func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge { | |||
| 25 | + | p := principal(ctx) | |||
| 26 | + | if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) { | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | state, reason := s.mirrors.Badge(ctx, p, repo) | |||
| 30 | + | if state != domain.MirrorFailing && state != domain.MirrorPaused { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()} | |||
| 34 | + | } | |||
A internal/adapters/web/mirror_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestMirrorBadge(t *testing.T) { | |
| 16 | + | repo := repoName(t, "proj") | |
| 17 | + | admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}}) | |
| 18 | + | reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}}) | |
| 19 | + | for _, tc := range []struct { | |
| 20 | + | name string | |
| 21 | + | p domain.Principal | |
| 22 | + | state domain.MirrorState | |
| 23 | + | shown bool | |
| 24 | + | lookup bool | |
| 25 | + | }{ | |
| 26 | + | {"owner failing", domain.Owner(), domain.MirrorFailing, true, true}, | |
| 27 | + | {"admin paused", admin, domain.MirrorPaused, true, true}, | |
| 28 | + | {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false}, | |
| 29 | + | {"reader", reader, domain.MirrorFailing, false, false}, | |
| 30 | + | {"ok", admin, domain.MirrorOK, false, true}, | |
| 31 | + | {"disabled", admin, domain.MirrorDisabled, false, true}, | |
| 32 | + | } { | |
| 33 | + | t.Run(tc.name, func(t *testing.T) { | |
| 34 | + | w := mocks.NewMirrorWarner(t) | |
| 35 | + | if tc.lookup { | |
| 36 | + | w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote") | |
| 37 | + | } | |
| 38 | + | s := (&Server{}).WithMirrorWarner(w) | |
| 39 | + | badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo) | |
| 40 | + | out := renderRail(t, SummaryPage{Mirror: badge}) | |
| 41 | + | if tc.shown { | |
| 42 | + | assert.Contains(t, out, `aria-labelledby="r-mirror"`) | |
| 43 | + | assert.Contains(t, out, "Mirror "+string(tc.state)) | |
| 44 | + | assert.Contains(t, out, "main diverged on remote") | |
| 45 | + | assert.Contains(t, out, "ermite mirror status proj") | |
| 46 | + | } else { | |
| 47 | + | assert.Nil(t, badge) | |
| 48 | + | assert.NotContains(t, out, `id="r-mirror"`) | |
| 49 | + | } | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo)) | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestMirrorBadge(t *testing.T) { | |||
| 16 | + | repo := repoName(t, "proj") | |||
| 17 | + | admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}}) | |||
| 18 | + | reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}}) | |||
| 19 | + | for _, tc := range []struct { | |||
| 20 | + | name string | |||
| 21 | + | p domain.Principal | |||
| 22 | + | state domain.MirrorState | |||
| 23 | + | shown bool | |||
| 24 | + | lookup bool | |||
| 25 | + | }{ | |||
| 26 | + | {"owner failing", domain.Owner(), domain.MirrorFailing, true, true}, | |||
| 27 | + | {"admin paused", admin, domain.MirrorPaused, true, true}, | |||
| 28 | + | {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false}, | |||
| 29 | + | {"reader", reader, domain.MirrorFailing, false, false}, | |||
| 30 | + | {"ok", admin, domain.MirrorOK, false, true}, | |||
| 31 | + | {"disabled", admin, domain.MirrorDisabled, false, true}, | |||
| 32 | + | } { | |||
| 33 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 34 | + | w := mocks.NewMirrorWarner(t) | |||
| 35 | + | if tc.lookup { | |||
| 36 | + | w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote") | |||
| 37 | + | } | |||
| 38 | + | s := (&Server{}).WithMirrorWarner(w) | |||
| 39 | + | badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo) | |||
| 40 | + | out := renderRail(t, SummaryPage{Mirror: badge}) | |||
| 41 | + | if tc.shown { | |||
| 42 | + | assert.Contains(t, out, `aria-labelledby="r-mirror"`) | |||
| 43 | + | assert.Contains(t, out, "Mirror "+string(tc.state)) | |||
| 44 | + | assert.Contains(t, out, "main diverged on remote") | |||
| 45 | + | assert.Contains(t, out, "ermite mirror status proj") | |||
| 46 | + | } else { | |||
| 47 | + | assert.Nil(t, badge) | |||
| 48 | + | assert.NotContains(t, out, `id="r-mirror"`) | |||
| 49 | + | } | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo)) | |||
| 53 | + | } | |||
M internal/adapters/web/pages.templ +11 -0
| @@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) { | |||
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | templ summaryRail(p SummaryPage) { | |
| 95 | + | if p.Mirror != nil { | |
| 96 | + | <section aria-labelledby="r-mirror"> | |
| 97 | + | <h2 id="r-mirror">Mirror</h2> | |
| 98 | + | <p>⚠ Mirror { p.Mirror.State }</p> | |
| 99 | + | if p.Mirror.Reason != "" { | |
| 100 | + | <p>{ p.Mirror.Reason }</p> | |
| 101 | + | } | |
| 102 | + | <p>Run <code>{ p.Mirror.Command }</code></p> | |
| 103 | + | </section> | |
| 104 | + | } | |
| 105 | + | ||
| 95 | 106 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | |
| 96 | 107 | </section> | |
| 97 | 108 | <section aria-labelledby="r-facts"> | |
| @@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) { | |||||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | templ summaryRail(p SummaryPage) { | 94 | templ summaryRail(p SummaryPage) { | ||
| 95 | + | if p.Mirror != nil { | |||
| 96 | + | <section aria-labelledby="r-mirror"> | |||
| 97 | + | <h2 id="r-mirror">Mirror</h2> | |||
| 98 | + | <p>⚠ Mirror { p.Mirror.State }</p> | |||
| 99 | + | if p.Mirror.Reason != "" { | |||
| 100 | + | <p>{ p.Mirror.Reason }</p> | |||
| 101 | + | } | |||
| 102 | + | <p>Run <code>{ p.Mirror.Command }</code></p> | |||
| 103 | + | </section> | |||
| 104 | + | } | |||
| 105 | + | ||||
| 95 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | 106 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | ||
| 96 | </section> | 107 | </section> | ||
| 97 | <section aria-labelledby="r-facts"> | 108 | <section aria-labelledby="r-facts"> | ||
M internal/adapters/web/pages_templ.go +324 -269 generated file: collapsed
M internal/adapters/web/summary.go +1 -0
| @@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 49 | 49 | now := s.cfg.Now() | |
| 50 | 50 | ||
| 51 | 51 | p := SummaryPage{ | |
| 52 | + | Mirror: s.mirrorBadge(ctx, d.repo.Name), | |
| 52 | 53 | AllHref: repoHref(d.name(), "commits", ref), | |
| 53 | 54 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |
| 54 | 55 | } | |
| @@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 49 | now := s.cfg.Now() | 49 | now := s.cfg.Now() | ||
| 50 | 50 | ||||
| 51 | p := SummaryPage{ | 51 | p := SummaryPage{ | ||
| 52 | + | Mirror: s.mirrorBadge(ctx, d.repo.Name), | |||
| 52 | AllHref: repoHref(d.name(), "commits", ref), | 53 | AllHref: repoHref(d.name(), "commits", ref), | ||
| 53 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | 54 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | ||
| 54 | } | 55 | } | ||
M internal/adapters/web/view.go +1 -0
| @@ -203,6 +203,7 @@ type CommitRow struct { | |||
| 203 | 203 | ||
| 204 | 204 | // SummaryPage is the repository summary. | |
| 205 | 205 | type SummaryPage struct { | |
| 206 | + | Mirror *MirrorBadge | |
| 206 | 207 | Facts []Fact | |
| 207 | 208 | Clone Clone | |
| 208 | 209 | Recent []CommitRow | |
| @@ -203,6 +203,7 @@ type CommitRow struct { | |||||
| 203 | 203 | ||||
| 204 | // SummaryPage is the repository summary. | 204 | // SummaryPage is the repository summary. | ||
| 205 | type SummaryPage struct { | 205 | type SummaryPage struct { | ||
| 206 | + | Mirror *MirrorBadge | |||
| 206 | Facts []Fact | 207 | Facts []Fact | ||
| 207 | Clone Clone | 208 | Clone Clone | ||
| 208 | Recent []CommitRow | 209 | Recent []CommitRow | ||
M internal/adapters/web/web.go +1 -0
| @@ -127,6 +127,7 @@ type Config struct { | |||
| 127 | 127 | ||
| 128 | 128 | // Server serves the pages. It is an http.Handler. | |
| 129 | 129 | type Server struct { | |
| 130 | + | mirrors ports.MirrorWarner | |
| 130 | 131 | cfg Config | |
| 131 | 132 | instance string | |
| 132 | 133 | origin string // scheme://host of the public URL, what a POST must come from | |
| @@ -127,6 +127,7 @@ type Config struct { | |||||
| 127 | 127 | ||||
| 128 | // Server serves the pages. It is an http.Handler. | 128 | // Server serves the pages. It is an http.Handler. | ||
| 129 | type Server struct { | 129 | type Server struct { | ||
| 130 | + | mirrors ports.MirrorWarner | |||
| 130 | cfg Config | 131 | cfg Config | ||
| 131 | instance string | 132 | instance string | ||
| 132 | origin string // scheme://host of the public URL, what a POST must come from | 133 | origin string // scheme://host of the public URL, what a POST must come from | ||