detached 157f3c9

feat(auto): add ImagePushDispatcher for event classification

commit
157f3c993672ce80e2893c9e5fd678c1287157ec
author
brice · · 2026-03-19 04:55 UTC
committer
brice · · 2026-03-19 05:13 UTC
parent
3daa036
signature
unsigned
2 files · +149 -0
A internal/usecase/auto/dispatcher.go +59 -0
@@ -0,0 +1,59 @@
1+package auto
2+
3+import (
4+ "context"
5+ "path/filepath"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// AutoConfigProvider exposes unified auto config to the dispatcher.
12+type AutoConfigProvider interface {
13+ IsAutoEnabled() bool
14+ IsPreviewEnabled() bool
15+ GetPreviewTagPatterns() []string
16+ GetPreviewConfig() domain.PreviewConfig
17+ GetAllowedDomains() []string
18+}
19+
20+// ImagePushDispatcher classifies image push events and delegates
21+// to either the auto-route handler or the auto-preview handler.
22+type ImagePushDispatcher struct {
23+ config AutoConfigProvider
24+ routeHandler out.EventHandler
25+ previewHandler out.EventHandler
26+}
27+
28+func NewImagePushDispatcher(config AutoConfigProvider, routeHandler, previewHandler out.EventHandler) *ImagePushDispatcher {
29+ return &ImagePushDispatcher{
30+ config: config,
31+ routeHandler: routeHandler,
32+ previewHandler: previewHandler,
33+ }
34+}
35+
36+func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool {
37+ return t == domain.EventImagePushed
38+}
39+
40+func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {
41+ payload, ok := event.Data.(domain.ImagePushedPayload)
42+ if !ok {
43+ return d.routeHandler.Handle(ctx, event)
44+ }
45+
46+ if d.config.IsPreviewEnabled() && d.matchesTagPatterns(payload.Reference) {
47+ return d.previewHandler.Handle(ctx, event)
48+ }
49+ return d.routeHandler.Handle(ctx, event)
50+}
51+
52+func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {
53+ for _, pattern := range d.config.GetPreviewTagPatterns() {
54+ if matched, _ := filepath.Match(pattern, reference); matched {
55+ return true
56+ }
57+ }
58+ return false
59+}
@@ -0,0 +1,59 @@
1+package auto
2+
3+import (
4+ "context"
5+ "path/filepath"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// AutoConfigProvider exposes unified auto config to the dispatcher.
12+type AutoConfigProvider interface {
13+ IsAutoEnabled() bool
14+ IsPreviewEnabled() bool
15+ GetPreviewTagPatterns() []string
16+ GetPreviewConfig() domain.PreviewConfig
17+ GetAllowedDomains() []string
18+}
19+
20+// ImagePushDispatcher classifies image push events and delegates
21+// to either the auto-route handler or the auto-preview handler.
22+type ImagePushDispatcher struct {
23+ config AutoConfigProvider
24+ routeHandler out.EventHandler
25+ previewHandler out.EventHandler
26+}
27+
28+func NewImagePushDispatcher(config AutoConfigProvider, routeHandler, previewHandler out.EventHandler) *ImagePushDispatcher {
29+ return &ImagePushDispatcher{
30+ config: config,
31+ routeHandler: routeHandler,
32+ previewHandler: previewHandler,
33+ }
34+}
35+
36+func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool {
37+ return t == domain.EventImagePushed
38+}
39+
40+func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {
41+ payload, ok := event.Data.(domain.ImagePushedPayload)
42+ if !ok {
43+ return d.routeHandler.Handle(ctx, event)
44+ }
45+
46+ if d.config.IsPreviewEnabled() && d.matchesTagPatterns(payload.Reference) {
47+ return d.previewHandler.Handle(ctx, event)
48+ }
49+ return d.routeHandler.Handle(ctx, event)
50+}
51+
52+func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {
53+ for _, pattern := range d.config.GetPreviewTagPatterns() {
54+ if matched, _ := filepath.Match(pattern, reference); matched {
55+ return true
56+ }
57+ }
58+ return false
59+}
A internal/usecase/auto/dispatcher_test.go +90 -0
@@ -0,0 +1,90 @@
1+package auto
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type mockHandler struct {
12+ called bool
13+}
14+
15+func (m *mockHandler) Handle(_ context.Context, _ domain.Event) error {
16+ m.called = true
17+ return nil
18+}
19+
20+func (m *mockHandler) CanHandle(t domain.EventType) bool {
21+ return t == domain.EventImagePushed
22+}
23+
24+type mockAutoConfig struct {
25+ previewEnabled bool
26+ tagPatterns []string
27+}
28+
29+func (m *mockAutoConfig) IsAutoEnabled() bool { return true }
30+func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.previewEnabled }
31+func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }
32+func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }
33+func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }
34+
35+func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {
36+ route := &mockHandler{}
37+ preview := &mockHandler{}
38+ d := NewImagePushDispatcher(
39+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
40+ route, preview,
41+ )
42+ event := domain.Event{
43+ Type: domain.EventImagePushed,
44+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
45+ }
46+ err := d.Handle(context.Background(), event)
47+ assert.NoError(t, err)
48+ assert.True(t, preview.called)
49+ assert.False(t, route.called)
50+}
51+
52+func TestDispatcher_NormalTag_DelegatesToRoute(t *testing.T) {
53+ route := &mockHandler{}
54+ preview := &mockHandler{}
55+ d := NewImagePushDispatcher(
56+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
57+ route, preview,
58+ )
59+ event := domain.Event{
60+ Type: domain.EventImagePushed,
61+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "v1.0.0"},
62+ }
63+ err := d.Handle(context.Background(), event)
64+ assert.NoError(t, err)
65+ assert.True(t, route.called)
66+ assert.False(t, preview.called)
67+}
68+
69+func TestDispatcher_PreviewDisabled_AlwaysRoute(t *testing.T) {
70+ route := &mockHandler{}
71+ preview := &mockHandler{}
72+ d := NewImagePushDispatcher(
73+ &mockAutoConfig{previewEnabled: false, tagPatterns: []string{"preview-*"}},
74+ route, preview,
75+ )
76+ event := domain.Event{
77+ Type: domain.EventImagePushed,
78+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
79+ }
80+ err := d.Handle(context.Background(), event)
81+ assert.NoError(t, err)
82+ assert.True(t, route.called)
83+ assert.False(t, preview.called)
84+}
85+
86+func TestDispatcher_CanHandle(t *testing.T) {
87+ d := &ImagePushDispatcher{}
88+ assert.True(t, d.CanHandle(domain.EventImagePushed))
89+ assert.False(t, d.CanHandle(domain.EventConfigReload))
90+}
@@ -0,0 +1,90 @@
1+package auto
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type mockHandler struct {
12+ called bool
13+}
14+
15+func (m *mockHandler) Handle(_ context.Context, _ domain.Event) error {
16+ m.called = true
17+ return nil
18+}
19+
20+func (m *mockHandler) CanHandle(t domain.EventType) bool {
21+ return t == domain.EventImagePushed
22+}
23+
24+type mockAutoConfig struct {
25+ previewEnabled bool
26+ tagPatterns []string
27+}
28+
29+func (m *mockAutoConfig) IsAutoEnabled() bool { return true }
30+func (m *mockAutoConfig) IsPreviewEnabled() bool { return m.previewEnabled }
31+func (m *mockAutoConfig) GetPreviewTagPatterns() []string { return m.tagPatterns }
32+func (m *mockAutoConfig) GetPreviewConfig() domain.PreviewConfig { return domain.PreviewConfig{} }
33+func (m *mockAutoConfig) GetAllowedDomains() []string { return nil }
34+
35+func TestDispatcher_PreviewTag_DelegatesToPreview(t *testing.T) {
36+ route := &mockHandler{}
37+ preview := &mockHandler{}
38+ d := NewImagePushDispatcher(
39+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
40+ route, preview,
41+ )
42+ event := domain.Event{
43+ Type: domain.EventImagePushed,
44+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
45+ }
46+ err := d.Handle(context.Background(), event)
47+ assert.NoError(t, err)
48+ assert.True(t, preview.called)
49+ assert.False(t, route.called)
50+}
51+
52+func TestDispatcher_NormalTag_DelegatesToRoute(t *testing.T) {
53+ route := &mockHandler{}
54+ preview := &mockHandler{}
55+ d := NewImagePushDispatcher(
56+ &mockAutoConfig{previewEnabled: true, tagPatterns: []string{"preview-*"}},
57+ route, preview,
58+ )
59+ event := domain.Event{
60+ Type: domain.EventImagePushed,
61+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "v1.0.0"},
62+ }
63+ err := d.Handle(context.Background(), event)
64+ assert.NoError(t, err)
65+ assert.True(t, route.called)
66+ assert.False(t, preview.called)
67+}
68+
69+func TestDispatcher_PreviewDisabled_AlwaysRoute(t *testing.T) {
70+ route := &mockHandler{}
71+ preview := &mockHandler{}
72+ d := NewImagePushDispatcher(
73+ &mockAutoConfig{previewEnabled: false, tagPatterns: []string{"preview-*"}},
74+ route, preview,
75+ )
76+ event := domain.Event{
77+ Type: domain.EventImagePushed,
78+ Data: domain.ImagePushedPayload{Name: "myapp", Reference: "preview-feat"},
79+ }
80+ err := d.Handle(context.Background(), event)
81+ assert.NoError(t, err)
82+ assert.True(t, route.called)
83+ assert.False(t, preview.called)
84+}
85+
86+func TestDispatcher_CanHandle(t *testing.T) {
87+ d := &ImagePushDispatcher{}
88+ assert.True(t, d.CanHandle(domain.EventImagePushed))
89+ assert.False(t, d.CanHandle(domain.EventConfigReload))
90+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close