Repository: gaqzi/incident-reviewer
Pull request #69: Add trigger store
Author: login: jainoopur
Created: 2025-03-20T10:04:15Z | Merged: 2025-04-21T00:39:07Z

opens bound triggers in a new page without anything else 
<img width="737" alt="image" src="https://github.com/user-attachments/assets/879efb2c-b7c5-4b83-ba40-53307a20bf42" />
 but the e2e test only asserts presence of newly added trigger


Diff:
diff --git a/internal/app/reviewer.go b/internal/app/reviewer.go
index 4f71ca2..86f142d 100644
--- a/internal/app/reviewer.go
+++ b/internal/app/reviewer.go
@@ -11,10 +11,13 @@ import (
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5/middleware"
 	"github.com/go-chi/httplog/v2"
+	"github.com/google/uuid"
 
 	"github.com/gaqzi/incident-reviewer/internal/app/web"
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
 	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing"
 	contribstorage "github.com/gaqzi/incident-reviewer/internal/normalized/contributing/storage"
+	"github.com/gaqzi/incident-reviewer/internal/normalized/storage"
 	"github.com/gaqzi/incident-reviewer/internal/reviewing"
 	reviewstorage "github.com/gaqzi/incident-reviewer/internal/reviewing/storage"
 )
@@ -83,7 +86,18 @@ func Start(ctx context.Context, cfg Config) (*Server, error) {
 	r.Route("/contributing-causes", web.ContributingCausesHandler(causeService))
 
 	reviewStore := reviewstorage.NewMemoryStore()
-	reviewService := reviewing.NewService(reviewStore, causeService)
+
+	triggerService := normalized.NewTriggerService(storage.NewTriggerMemoryStore())
+	trigger := normalized.Trigger{}
+	trigger.ID = uuid.MustParse("6A195282-04CA-4405-A6F1-678C525A001B")
+	trigger.Name = "Traffic increase"
+	trigger.Description = "More users than normal"
+	_, err = triggerService.Save(ctx, trigger)
+	if err != nil {
+		return nil, fmt.Errorf("failed to add default trigger: %w", err)
+	}
+
+	reviewService := reviewing.NewService(reviewStore, causeService, triggerService)
 	r.Route("/reviews", web.ReviewsHandler(reviewService, causeService))
 
 	go (func() {
diff --git a/internal/app/web/reviews.go b/internal/app/web/reviews.go
index da3052f..664e9a2 100644
--- a/internal/app/web/reviews.go
+++ b/internal/app/web/reviews.go
@@ -35,6 +35,7 @@ type reviewingService interface {
 	BindContributingCause(ctx context.Context, reviewID uuid.UUID, causeID uuid.UUID, boundCause reviewing.BoundCause) error
 	GetBoundContributingCause(ctx context.Context, reviewID uuid.UUID, boundCauseID uuid.UUID) (reviewing.BoundCause, error)
 	UpdateBoundContributingCause(ctx context.Context, reviewID uuid.UUID, boundCause reviewing.BoundCause) (reviewing.BoundCause, error)
+	BindTrigger(ctx context.Context, reviewID uuid.UUID, triggerID uuid.UUID, trigger reviewing.UnboundTrigger) error
 }
 
 type causeAller interface {
@@ -114,6 +115,8 @@ func ReviewsHandler(service reviewingService, causeStore causeAller) func(chi.Ro
 			r.Post("/contributing-causes", app.BindContributingCause)
 			r.Get("/contributing-causes/{boundCauseID}/edit", app.EditBoundContributingCause)
 			r.Post("/contributing-causes/{boundCauseID}/edit", app.UpdateBoundContributingCause)
+
+			r.Post("/triggers", app.BindTrigger)
 		})
 	}
 }
@@ -133,7 +136,8 @@ type ReviewBasic struct {
 	ReportTrigger       string    `form:"reportTrigger"`
 
 	// Related items that are not changed from the forms but by other calls
-	BoundCauses []BoundCauseBasic
+	BoundCauses   []BoundCauseBasic
+	BoundTriggers []BoundTriggerBasic
 
 	UpdatedAt time.Time
 	CreatedAt time.Time
@@ -158,6 +162,19 @@ type BoundCauseBasic struct {
 	IsProximalCause bool
 }
 
+type BoundTriggerBasic struct {
+	ID   uuid.UUID
+	Name string
+	Why  string
+}
+
+type TriggerForm struct {
+	ID        uuid.UUID `form:"id"`
+	ReviewID  uuid.UUID `form:"reviewID"`
+	TriggerID uuid.UUID `form:"triggerID"`
+	Why       string    `form:"why"`
+}
+
 type ContributingCauseBasic struct {
 	ID          uuid.UUID
 	Name        string
@@ -248,9 +265,11 @@ func (a *reviewsHandler) Show(w http.ResponseWriter, r *http.Request) {
 	data := map[string]any{
 		"Review":             httpReview,
 		"BoundCauses":        httpReview.BoundCauses,
+		"BoundTriggers":      httpReview.BoundTriggers,
 		"ContributingCauses": convertContributingCauseToHttpObjects(contributingCauses),
 		"ReviewID":           reviewID,
 		"ContributingCause":  BoundCauseBasic{},
+		"BoundTrigger":       BoundTriggerBasic{},
 	}
 
 	if err := a.pp.RenderInLayout(w, "layouts/standard.html", "reviews/show.html", map[string]any{"Data": data}); err != nil {
@@ -561,6 +580,67 @@ func (a *reviewsHandler) loadContributingCauses(ctx context.Context, h *htmx.Han
 	return contributingCauses, nil
 }
 
+func (a *reviewsHandler) BindTrigger(w http.ResponseWriter, r *http.Request) {
+	h := a.htmx.NewHandler(w, r)
+
+	if !h.IsHxRequest() {
+		h.WriteHeader(http.StatusNotFound)
+		h.JustWriteString("non-htmx requests not yet supported")
+	}
+
+	reviewID, err := uuid.Parse(r.PathValue("id"))
+	if err != nil {
+		slog.Error("failed to parse id for create trigger", "id", r.PathValue("id"), "error", err)
+		h.WriteHeader(http.StatusBadRequest)
+		h.JustWriteString("invalid id")
+		return
+	}
+
+	if err := r.ParseForm(); err != nil {
+		slog.Error("failed to parse form", "error", err)
+		h.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	var triggerForm TriggerForm
+	if err := a.decoder.Decode(&triggerForm, r.PostForm); err != nil {
+		slog.Error("failed to decode basic trigger form", "error", err)
+		h.WriteHeader(http.StatusBadRequest)
+		h.JustWriteString(err.Error())
+		return
+	}
+
+	if err := a.service.BindTrigger(
+		r.Context(),
+		reviewID,
+		triggerForm.TriggerID,
+		reviewing.UnboundTrigger{Why: triggerForm.Why},
+	); err != nil {
+		slog.Error("failed to bind trigger", "reviewID", reviewID, "error", err)
+		h.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	review, err := a.loadReview(r.Context(), h, reviewID)
+	if err != nil {
+		return
+	}
+	httpReview := convertToHttpObject(review)
+
+	data := map[string]any{
+		"Review":        httpReview,
+		"ReviewID":      reviewID,
+		"BoundTrigger":  BoundTriggerBasic{},
+		"BoundTriggers": httpReview.BoundTriggers,
+	}
+
+	if err := a.pp.Render(w, "reviews/show/_triggers.html", map[string]any{"Data": data}); err != nil {
+		slog.Error("failed to render bind trigger", "reviewID", reviewID, "data", data, "error", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func convertToHttpObjects(rs []reviewing.Review) []ReviewBasic {
 	ret := make([]ReviewBasic, 0, len(rs))
 
@@ -577,6 +657,11 @@ func convertToHttpObject(r reviewing.Review) ReviewBasic {
 		causes = append(causes, toBoundCauseBasic(cause))
 	}
 
+	triggers := make([]BoundTriggerBasic, 0, len(r.BoundTriggers))
+	for _, trigger := range r.BoundTriggers {
+		triggers = append(triggers, toBoundTriggerBasic(trigger))
+	}
+
 	return ReviewBasic{
 		ID:                  r.ID,
 		URL:                 r.URL,
@@ -587,7 +672,8 @@ func convertToHttpObject(r reviewing.Review) ReviewBasic {
 		ReportProximalCause: r.ReportProximalCause,
 		ReportTrigger:       r.ReportTrigger,
 
-		BoundCauses: causes,
+		BoundCauses:   causes,
+		BoundTriggers: triggers,
 
 		CreatedAt: r.CreatedAt,
 		UpdatedAt: r.UpdatedAt,
@@ -604,6 +690,14 @@ func toBoundCauseBasic(cause reviewing.BoundCause) BoundCauseBasic {
 	}
 }
 
+func toBoundTriggerBasic(trigger reviewing.BoundTrigger) BoundTriggerBasic {
+	return BoundTriggerBasic{
+		ID:   trigger.ID,
+		Name: trigger.Trigger.Name,
+		Why:  trigger.Why,
+	}
+}
+
 func convertContributingCauseToHttpObjects(ccs []contributing.Cause) map[string][]ContributingCauseBasic {
 	ret := make(map[string][]ContributingCauseBasic)
 
diff --git a/internal/app/web/templates/reviews/_triggers.html b/internal/app/web/templates/reviews/_triggers.html
new file mode 100644
index 0000000..78a0baf
--- /dev/null
+++ b/internal/app/web/templates/reviews/_triggers.html
@@ -0,0 +1,23 @@
+<section id="triggers">
+    <h1>Triggers</h1>
+    <form class="new" method="post" action="/reviews/{{ .Data.ReviewID }}/triggers">
+        <label>
+            Trigger:
+            <select  name="triggerID" required>
+                <option disabled selected>-- select --</option>
+                <option value="6A195282-04CA-4405-A6F1-678C525A001B">Traffic increase</option>
+            </select>
+        </label>
+        <label>
+            Why:
+            <textarea name="why"></textarea>
+        </label>
+        <button type="submit">Add</button>
+    </form>
+
+    <ul class="listing">
+        {{ range .Data.BoundTriggers }}
+           <li>{{ .Name}} -- {{ .Why }}</li>
+        {{ end }}
+    </ul>
+</section>
\ No newline at end of file
diff --git a/internal/app/web/templates/reviews/show.html b/internal/app/web/templates/reviews/show.html
index 55915f0..e1d127d 100644
--- a/internal/app/web/templates/reviews/show.html
+++ b/internal/app/web/templates/reviews/show.html
@@ -26,3 +26,4 @@ <h1 class="title">{{ .Title }}</h1>
 {{ end }}
 
 {{ template "reviews/show/_contributing-causes.html" . }}
+{{ template "reviews/show/_triggers.html" . }}
diff --git a/internal/app/web/templates/reviews/show/_triggers.html b/internal/app/web/templates/reviews/show/_triggers.html
new file mode 100644
index 0000000..c914b3e
--- /dev/null
+++ b/internal/app/web/templates/reviews/show/_triggers.html
@@ -0,0 +1,23 @@
+<section id="triggers" hx-target="this" hx-swap="outerHTML">
+    <h1>Triggers</h1>
+    <form class="new" method="post" action="/reviews/{{ .Data.ReviewID }}/triggers">
+        <label>
+            Trigger:
+            <select  name="triggerID" required>
+                <option disabled selected>-- select --</option>
+                <option value="6A195282-04CA-4405-A6F1-678C525A001B">Traffic increase</option>
+            </select>
+        </label>
+        <label>
+            Why:
+            <textarea name="why"></textarea>
+        </label>
+        <button type="submit">Add</button>
+    </form>
+
+    <ul class="listing">
+        {{ range .Data.BoundTriggers }}
+           <li>{{ .Name}} -- {{ .Why }}</li>
+        {{ end }}
+    </ul>
+</section>
diff --git a/internal/normalized/contributing/storage/errors.go b/internal/normalized/contributing/storage/errors.go
index c03e5c7..2721430 100644
--- a/internal/normalized/contributing/storage/errors.go
+++ b/internal/normalized/contributing/storage/errors.go
@@ -17,3 +17,11 @@ func (e *NoCauseError) Error() string {
 
 // ErrNoID indicates that the passed in uuid ID is blank/uninitialized.
 var ErrNoID = errors.New("can't store contributing cause because ID is not set")
+
+type NoTriggerError struct {
+	ID uuid.UUID
+}
+
+func (e *NoTriggerError) Error() string {
+	return fmt.Sprintf("trigger not found by id: %d", e.ID)
+}
diff --git a/internal/normalized/storage.go b/internal/normalized/storage.go
new file mode 100644
index 0000000..8b12d27
--- /dev/null
+++ b/internal/normalized/storage.go
@@ -0,0 +1,15 @@
+package normalized
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+)
+
+type TriggerStorage interface {
+	Get(ctx context.Context, id uuid.UUID) (Trigger, error)
+
+	Save(ctx context.Context, trigger Trigger) (Trigger, error)
+
+	All(ctx context.Context) ([]Trigger, error)
+}
diff --git a/internal/normalized/storage/storage_test.go b/internal/normalized/storage/storage_test.go
new file mode 100644
index 0000000..7514291
--- /dev/null
+++ b/internal/normalized/storage/storage_test.go
@@ -0,0 +1,114 @@
+package storage_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+
+	storage2 "github.com/gaqzi/incident-reviewer/internal/normalized/contributing/storage"
+	"github.com/gaqzi/incident-reviewer/internal/normalized/storage"
+
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
+	"github.com/gaqzi/incident-reviewer/test/a"
+)
+
+func TestTriggerMemoryStore(t *testing.T) {
+	TriggerStorageTest(t, context.Background(), func() normalized.TriggerStorage {
+		return storage.NewTriggerMemoryStore()
+	})
+}
+
+func TriggerStorageTest(t *testing.T, ctx context.Context, storeFactory func() normalized.TriggerStorage) {
+	t.Run("Save", func(t *testing.T) {
+		t.Run("returns an error when trying to save without an ID set", func(t *testing.T) {
+			store := storeFactory()
+
+			_, actual := store.Save(ctx, normalized.Trigger{})
+
+			require.ErrorIs(t, actual, storage2.ErrNoID, "expected the sentinel error for not having an ID set")
+		})
+
+		t.Run("an object with the ID set is saved without errors", func(t *testing.T) {
+			Trigger := normalized.NewTrigger()
+			store := storeFactory()
+
+			_, err := store.Save(ctx, Trigger)
+
+			require.NoError(t, err, "expected to have saved when all fields are set")
+		})
+	})
+
+	t.Run("Get", func(t *testing.T) {
+		t.Run("returns an error when an item with the given PK doesn't exist in the store", func(t *testing.T) {
+			store := storeFactory()
+
+			_, err := store.Get(ctx, uuid.Nil)
+			require.Error(t, err, "expected to not have found an item when it's not in the store")
+
+			var actualErr *storage2.NoTriggerError
+			require.ErrorAs(t, err, &actualErr, "expected the specific error for not found")
+		})
+
+		t.Run("after saving, gets back the same object as save when asking by ID", func(t *testing.T) {
+			store := storeFactory()
+			expected, err := store.Save(ctx, a.NormalizedTrigger().Build())
+			require.NoError(t, err, "expected the valid review to have been saved successfully")
+
+			actual, err := store.Get(ctx, expected.ID)
+			require.NoError(t, err, "expected to have fetched successfully when just saving the object")
+
+			require.Equal(t, actual, expected, "expected the objects to have the same info when no changes between save and fetch")
+		})
+	})
+
+	t.Run("All", func(t *testing.T) {
+		t.Run("with no stored reviews it returns an empty list", func(t *testing.T) {
+			store := storeFactory()
+
+			reviews, err := store.All(ctx)
+			require.NoError(t, err)
+
+			require.Empty(t, reviews, "expected to have gotten back no items")
+		})
+
+		t.Run("returns the only stored item when only one exists", func(t *testing.T) {
+			store := storeFactory()
+			trigger, err := store.Save(ctx, a.NormalizedTrigger().Build())
+			require.NoError(t, err, "expected to have saved successfully")
+
+			actual, err := store.All(ctx)
+			require.NoError(t, err)
+
+			require.NotEmpty(t, actual)
+			require.Equal(
+				t,
+				[]normalized.Trigger{trigger},
+				actual,
+				"expected to have gotten back an item matching the only stored one",
+			)
+		})
+
+		t.Run("with multiple reviews, returns them in descending creation order", func(t *testing.T) {
+			store := storeFactory()
+			Trigger1, err := store.Save(ctx, a.NormalizedTrigger().Build())
+			require.NoError(t, err, "expected to have saved successfully")
+			Trigger2, err := store.Save(ctx, a.NormalizedTrigger().WithID(uuid.Must(uuid.NewV7())).WithName("Unbounded resource utilization").Build())
+			require.NoError(t, err)
+
+			actual, err := store.All(ctx)
+			require.NoError(t, err)
+
+			require.Equal(
+				t,
+				[]normalized.Trigger{
+					Trigger2,
+					Trigger1,
+				},
+				actual,
+				"expected the most recently created item to be returned first",
+			)
+		})
+	})
+}
diff --git a/internal/normalized/storage/triggermemory.go b/internal/normalized/storage/triggermemory.go
new file mode 100644
index 0000000..6f7e992
--- /dev/null
+++ b/internal/normalized/storage/triggermemory.go
@@ -0,0 +1,66 @@
+package storage
+
+import (
+	"context"
+	"maps"
+	"slices"
+
+	"github.com/google/uuid"
+
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
+	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing/storage"
+)
+
+type TriggerMemoryStore struct {
+	data map[uuid.UUID]normalized.Trigger
+}
+
+func NewTriggerMemoryStore() *TriggerMemoryStore {
+	return &TriggerMemoryStore{
+		data: make(map[uuid.UUID]normalized.Trigger),
+	}
+}
+
+func (s *TriggerMemoryStore) Get(_ context.Context, id uuid.UUID) (normalized.Trigger, error) {
+	trigger, ok := s.data[id]
+	if !ok {
+		return normalized.Trigger{}, &storage.NoTriggerError{ID: id}
+	}
+	return trigger, nil
+}
+
+func (s *TriggerMemoryStore) Save(_ context.Context, trigger normalized.Trigger) (normalized.Trigger, error) {
+	if trigger.ID == uuid.Nil {
+		return normalized.Trigger{}, storage.ErrNoID
+	}
+
+	s.data[trigger.ID] = trigger
+
+	return trigger, nil
+}
+
+func (s *TriggerMemoryStore) All(_ context.Context) ([]normalized.Trigger, error) {
+	ret := make([]normalized.Trigger, 0, len(s.data))
+
+	// Sort all the keys for the store, which returns keys in a non-deterministic order,
+	// and the sort order is 1, 2, 3… by the ID, which is monotonically incrementing
+	keys := slices.SortedFunc(maps.Keys(s.data), func(u uuid.UUID, u2 uuid.UUID) int {
+		t1, t2 := u.Time(), u2.Time()
+		switch {
+		case t1 < t2:
+			return -1
+		case t1 > t2:
+			return 1
+		default:
+			return 0
+		}
+	})
+	// and since I want them returned with most recent first, reverse it after sorting
+	slices.Reverse(keys)
+
+	for _, r := range keys {
+		ret = append(ret, s.data[r])
+	}
+
+	return ret, nil
+}
diff --git a/internal/normalized/trigger.go b/internal/normalized/trigger.go
new file mode 100644
index 0000000..b535425
--- /dev/null
+++ b/internal/normalized/trigger.go
@@ -0,0 +1,75 @@
+package normalized
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/gaqzi/incident-reviewer/internal/platform/validate"
+)
+
+type Trigger struct {
+	ID          uuid.UUID `validate:"required"`
+	Name        string    `validate:"required"`
+	Description string    `validate:"required"`
+
+	CreatedAt time.Time
+	UpdatedAt time.Time
+}
+
+func (t Trigger) updateTimestamps() Trigger {
+	now := time.Now()
+	if t.CreatedAt.IsZero() {
+		t.CreatedAt = now
+	}
+	t.UpdatedAt = now
+
+	return t
+}
+
+func NewTrigger() Trigger {
+	return Trigger{ID: uuid.Must(uuid.NewV7())}
+}
+
+type TriggerService struct {
+	store TriggerStorage
+}
+
+func NewTriggerService(store TriggerStorage) *TriggerService {
+	return &TriggerService{store}
+}
+
+func (s *TriggerService) Save(ctx context.Context, t Trigger) (Trigger, error) {
+	if err := validate.Struct(ctx, t); err != nil {
+		return t, fmt.Errorf("failed to validate trigger: %w", err)
+	}
+
+	t = t.updateTimestamps()
+
+	t, err := s.store.Save(ctx, t)
+	if err != nil {
+		return t, fmt.Errorf("failed to store normalized trigger: %w", err)
+	}
+
+	return t, nil
+}
+
+func (s *TriggerService) All(ctx context.Context) ([]Trigger, error) {
+	ret, err := s.store.All(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("unable to get all triggers from storage: %w", err)
+	}
+
+	return ret, nil
+}
+
+func (s *TriggerService) Get(ctx context.Context, id uuid.UUID) (Trigger, error) {
+	t, err := s.store.Get(ctx, id)
+	if err != nil {
+		return Trigger{}, fmt.Errorf("failed to get trigger: %w", err)
+	}
+
+	return t, nil
+}
diff --git a/internal/normalized/trigger_test.go b/internal/normalized/trigger_test.go
new file mode 100644
index 0000000..13716bc
--- /dev/null
+++ b/internal/normalized/trigger_test.go
@@ -0,0 +1,162 @@
+package normalized_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/go-playground/validator/v10"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
+	"github.com/gaqzi/incident-reviewer/test/a"
+)
+
+type triggerStorageMock struct {
+	mock.Mock
+}
+
+func (m *triggerStorageMock) Get(ctx context.Context, id uuid.UUID) (normalized.Trigger, error) {
+	args := m.Called(ctx, id)
+	return args.Get(0).(normalized.Trigger), args.Error(1)
+}
+
+func (m *triggerStorageMock) Save(ctx context.Context, trigger normalized.Trigger) (normalized.Trigger, error) {
+	args := m.Called(ctx, trigger)
+	return args.Get(0).(normalized.Trigger), args.Error(1)
+}
+
+func (m *triggerStorageMock) All(ctx context.Context) ([]normalized.Trigger, error) {
+	args := m.Called(ctx)
+	return args.Get(0).([]normalized.Trigger), args.Error(1)
+}
+
+func TestNormalizedTriggerService_Save(t *testing.T) {
+	t.Run("sets the Created and Updated at when they're not set", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.
+			On("Save", mock.Anything, mock.MatchedBy(func(c normalized.Trigger) bool {
+				return !c.CreatedAt.IsZero() && !c.UpdatedAt.IsZero() && c.CreatedAt.Equal(c.UpdatedAt)
+			})).
+			Return(normalized.Trigger{}, nil)
+		service := normalized.NewTriggerService(storage)
+
+		_, err := service.Save(context.Background(), a.NormalizedTrigger().IsNotSaved().Build())
+
+		require.NoError(t, err)
+	})
+
+	t.Run("sets the UpdatedAt when updating a previously saved item", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.
+			On("Save", mock.Anything, mock.MatchedBy(func(c normalized.Trigger) bool {
+				return !c.UpdatedAt.IsZero() && c.UpdatedAt.After(c.CreatedAt)
+			})).
+			Return(normalized.Trigger{}, nil)
+		service := normalized.NewTriggerService(storage)
+
+		_, err := service.Save(context.Background(), a.NormalizedTrigger().IsSaved().Build())
+
+		require.NoError(t, err)
+	})
+
+	t.Run("validate the Trigger object before saving", func(t *testing.T) {
+		service := normalized.NewTriggerService(nil)
+
+		_, actual := service.Save(context.Background(), normalized.Trigger{})
+
+		require.Error(t, actual)
+		require.ErrorContains(t, actual, "failed to validate trigger:")
+		var errs validator.ValidationErrors
+		require.ErrorAs(t, actual, &errs)
+		require.GreaterOrEqual(t, len(errs), 3, "expected to have at minimum 3 errors for the required fields")
+	})
+
+	t.Run("wraps any error from the store and returns it", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.On("Save", mock.Anything, mock.Anything).
+			Return(normalized.Trigger{}, errors.New("uh-oh"))
+		service := normalized.NewTriggerService(storage)
+
+		_, err := service.Save(context.Background(), a.NormalizedTrigger().Build())
+
+		require.Error(t, err, "expected to have failed when the underlying storage always fails")
+		require.ErrorContains(t, err, "failed to store normalized trigger:")
+	})
+
+	t.Run("on successful save returns the updated trigger", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.On("Save", mock.Anything, mock.Anything).
+			Return(a.NormalizedTrigger().Build(), nil)
+		service := normalized.NewTriggerService(storage)
+
+		actual, err := service.Save(context.Background(), a.NormalizedTrigger().IsNotSaved().Build())
+
+		require.NoError(t, err)
+		require.Equal(t, a.NormalizedTrigger().Build(), actual)
+	})
+}
+
+func TestNormalizedTriggerService_Get(t *testing.T) {
+	t.Run("wraps any storage error and returns it", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.On("Get", mock.Anything, mock.Anything).Return(normalized.Trigger{}, errors.New("uh-oh"))
+		service := normalized.NewTriggerService(storage)
+
+		_, actual := service.Get(context.Background(), uuid.Nil)
+
+		require.ErrorContains(t, actual, "failed to get trigger:")
+	})
+
+	t.Run("with no errors from storage return the object as-is", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		expected := a.NormalizedTrigger().Build()
+		storage.On("Get", mock.Anything, expected.ID).Return(expected, nil)
+		service := normalized.NewTriggerService(storage)
+
+		actual, err := service.Get(context.Background(), expected.ID)
+
+		require.NoError(t, err)
+		require.Equal(t, expected, actual, "expected an unchanged normalized trigger back")
+	})
+}
+
+func TestNormalizedTriggerService_All(t *testing.T) {
+	t.Run("returns a wrapped error if one is returned from the storage", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.On("All", mock.Anything).Return(([]normalized.Trigger)(nil), errors.New("uh-oh"))
+		ctx := context.Background()
+		service := normalized.NewTriggerService(storage)
+
+		_, actual := service.All(ctx)
+
+		require.Error(t, actual, "expected an error to be returned since it's hardcoded to always give one")
+		require.ErrorContains(t, actual, "unable to get all triggers from storage:")
+	})
+
+	t.Run("returns the returned object when no errors", func(t *testing.T) {
+		storage := new(triggerStorageMock)
+		storage.Test(t)
+		storage.On("All", mock.Anything).Return([]normalized.Trigger{a.NormalizedTrigger().Build()}, nil)
+		ctx := context.Background()
+		service := normalized.NewTriggerService(storage)
+
+		actual, err := service.All(ctx)
+		require.NoError(t, err)
+
+		require.Equal(
+			t,
+			[]normalized.Trigger{a.NormalizedTrigger().Build()},
+			actual,
+		)
+	})
+}
diff --git a/internal/reviewing/review.go b/internal/reviewing/review.go
index 5fc640f..7e3c766 100644
--- a/internal/reviewing/review.go
+++ b/internal/reviewing/review.go
@@ -8,6 +8,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
+
 	"github.com/google/uuid"
 
 	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing"
@@ -24,7 +26,8 @@ type Review struct {
 	ReportProximalCause string    `validate:"required"`
 	ReportTrigger       string    `validate:"required"`
 
-	BoundCauses []BoundCause
+	BoundCauses   []BoundCause
+	BoundTriggers []BoundTrigger
 
 	CreatedAt time.Time
 	UpdatedAt time.Time
@@ -109,6 +112,18 @@ func (r Review) UpdateBoundContributingCause(o BoundCause) (Review, error) {
 	return r, nil
 }
 
+func (r Review) BindTrigger(t normalized.Trigger, ubt UnboundTrigger) (Review, error) {
+	bt := BoundTrigger{
+		ID:             uuid.Must(uuid.NewV7()),
+		Trigger:        t,
+		UnboundTrigger: ubt,
+	}
+
+	r.BoundTriggers = append(r.BoundTriggers, bt)
+
+	return r, nil
+}
+
 type BoundCause struct {
 	ID              uuid.UUID
 	Cause           contributing.Cause `validate:"required"`
@@ -116,6 +131,16 @@ type BoundCause struct {
 	IsProximalCause bool
 }
 
+type UnboundTrigger struct {
+	Why string `validate:"required"`
+}
+
+type BoundTrigger struct {
+	ID      uuid.UUID
+	Trigger normalized.Trigger `validate:"required"`
+	UnboundTrigger
+}
+
 func NewBoundCause() BoundCause {
 	return BoundCause{ID: uuid.Must(uuid.NewV7())}
 }
@@ -124,10 +149,48 @@ type causeStore interface {
 	Get(ctx context.Context, id uuid.UUID) (contributing.Cause, error)
 }
 
+type triggerStore interface {
+	Get(ctx context.Context, id uuid.UUID) (normalized.Trigger, error)
+}
+
 type Service struct {
-	reviewStore Storage
-	causeStore  causeStore
-	action      *action.Mapper
+	reviewStore  Storage
+	causeStore   causeStore
+	action       *action.Mapper
+	triggerStore triggerStore
+}
+
+func (s *Service) BindTrigger(ctx context.Context, reviewID uuid.UUID, triggerID uuid.UUID, unboundTrigger UnboundTrigger) error {
+	review, err := s.reviewStore.Get(ctx, reviewID)
+	if err != nil {
+		return fmt.Errorf("failed to get review: %w", err)
+	}
+
+	trigger, err := s.triggerStore.Get(ctx, triggerID)
+	if err != nil {
+		return fmt.Errorf("failed to get trigger: %w", err)
+	}
+
+	doer, err := s.action.Get("BindTrigger")
+	if err != nil {
+		return fmt.Errorf("failed to get action for binding trigger: %w", err)
+	}
+	do, ok := doer.(func(Review, normalized.Trigger, UnboundTrigger) (Review, error))
+	if !ok {
+		return fmt.Errorf("failed to cast action for binding trigger: %w", err)
+	}
+
+	review, err = do(review, trigger, unboundTrigger)
+	if err != nil {
+		return fmt.Errorf("failed binding trigger to review: %w", err)
+	}
+
+	_, err = s.Save(ctx, review)
+	if err != nil {
+		return fmt.Errorf("failed to save review: %w", err)
+	}
+
+	return nil
 }
 
 type Option func(s *Service)
@@ -138,11 +201,12 @@ func WithActionMapper(mapper *action.Mapper) Option {
 	}
 }
 
-func NewService(reviewStore Storage, causeStore causeStore, opts ...Option) *Service {
+func NewService(reviewStore Storage, causeStore causeStore, triggerStore triggerStore, opts ...Option) *Service {
 	s := Service{
-		reviewStore: reviewStore,
-		causeStore:  causeStore,
-		action:      reviewServiceActions(),
+		reviewStore:  reviewStore,
+		causeStore:   causeStore,
+		triggerStore: triggerStore,
+		action:       reviewServiceActions(),
 	}
 
 	for _, opt := range opts {
diff --git a/internal/reviewing/review_test.go b/internal/reviewing/review_test.go
index bb3f69f..179f2ca 100644
--- a/internal/reviewing/review_test.go
+++ b/internal/reviewing/review_test.go
@@ -7,30 +7,32 @@ import (
 	"testing"
 
 	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
 	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing"
 	"github.com/gaqzi/incident-reviewer/internal/platform/action"
 	"github.com/gaqzi/incident-reviewer/internal/reviewing"
 	"github.com/gaqzi/incident-reviewer/test/a"
 )
 
-type storageMock struct {
+type reviewStorageMock struct {
 	mock.Mock
 }
 
-func (m *storageMock) Save(ctx context.Context, review reviewing.Review) (reviewing.Review, error) {
+func (m *reviewStorageMock) Save(ctx context.Context, review reviewing.Review) (reviewing.Review, error) {
 	args := m.Called(ctx, review)
 	return args.Get(0).(reviewing.Review), args.Error(1)
 }
 
-func (m *storageMock) Get(ctx context.Context, reviewID uuid.UUID) (reviewing.Review, error) {
+func (m *reviewStorageMock) Get(ctx context.Context, reviewID uuid.UUID) (reviewing.Review, error) {
 	args := m.Called(ctx, reviewID)
 	return args.Get(0).(reviewing.Review), args.Error(1)
 }
 
-func (m *storageMock) All(ctx context.Context) ([]reviewing.Review, error) {
+func (m *reviewStorageMock) All(ctx context.Context) ([]reviewing.Review, error) {
 	args := m.Called(ctx)
 	return args.Get(0).([]reviewing.Review), args.Error(1)
 }
@@ -44,17 +46,29 @@ func (m *causeStorageMock) Get(ctx context.Context, id uuid.UUID) (contributing.
 	return args.Get(0).(contributing.Cause), args.Error(1)
 }
 
+type triggerStorageMock struct {
+	mock.Mock
+}
+
+func (m *triggerStorageMock) Get(ctx context.Context, triggerID uuid.UUID) (normalized.Trigger, error) {
+	args := m.Called(ctx, triggerID)
+
+	return args.Get(0).(normalized.Trigger), args.Error(1)
+}
+
 type builderService struct {
-	reviewStorage *storageMock
-	causeStorage  *causeStorageMock
-	actionMapper  *action.Mapper
+	reviewStorage  *reviewStorageMock
+	causeStorage   *causeStorageMock
+	triggerStorage *triggerStorageMock
+	actionMapper   *action.Mapper
 }
 
 func newService() builderService {
 	return builderService{
-		reviewStorage: new(storageMock),
-		causeStorage:  new(causeStorageMock),
-		actionMapper:  &action.Mapper{},
+		reviewStorage:  new(reviewStorageMock),
+		causeStorage:   new(causeStorageMock),
+		triggerStorage: new(triggerStorageMock),
+		actionMapper:   &action.Mapper{},
 	}
 }
 
@@ -64,7 +78,9 @@ func (b builderService) Build(t *testing.T) *reviewing.Service {
 	rs.Test(t)
 	cs := b.causeStorage
 	cs.Test(t)
-	return reviewing.NewService(rs, cs, reviewing.WithActionMapper(b.actionMapper))
+	ts := b.triggerStorage
+	ts.Test(t)
+	return reviewing.NewService(rs, cs, ts, reviewing.WithActionMapper(b.actionMapper))
 }
 
 func (b builderService) getReview(r reviewing.Review) builderService {
@@ -147,6 +163,22 @@ func (b builderService) getCauseFail(err ...error) builderService {
 	return b
 }
 
+func (b builderService) getTrigger(t normalized.Trigger) builderService {
+	b.triggerStorage.On("Get", mock.Anything, t.ID).Return(t, nil)
+
+	return b
+}
+
+func (b builderService) getTriggerFail(err ...error) builderService {
+	if err == nil {
+		err = append(err, errors.New("uh-oh"))
+	}
+
+	b.triggerStorage.On("Get", mock.Anything, mock.Anything).Return(normalized.Trigger{}, err[0])
+
+	return b
+}
+
 func (b builderService) getCause(cause contributing.Cause) builderService {
 	b.causeStorage.On("Get", mock.Anything, cause.ID).Return(cause, nil)
 
@@ -200,6 +232,30 @@ func (b builderService) updateBoundContributingCauseAction(er reviewing.Review,
 	return b
 }
 
+func (b builderService) bindTriggerActionFail(err ...error) builderService {
+	if err == nil {
+		err = append(err, errors.New("uh-oh"))
+	}
+
+	b.actionMapper.Add("BindTrigger", func(_ reviewing.Review, _ normalized.Trigger, _ reviewing.UnboundTrigger) (reviewing.Review, error) {
+		return reviewing.Review{}, err[0]
+	})
+
+	return b
+}
+
+func (b builderService) bindTriggerAction(er reviewing.Review, et normalized.Trigger, eut reviewing.UnboundTrigger) builderService {
+	b.actionMapper.Add("BindTrigger", func(r reviewing.Review, t normalized.Trigger, ut reviewing.UnboundTrigger) (reviewing.Review, error) {
+		if !reflect.DeepEqual(er, r) ||
+			!reflect.DeepEqual(et, t) ||
+			!reflect.DeepEqual(eut, ut) {
+			return reviewing.Review{}, errors.New("the passed in values don't match the expected values")
+		}
+		return r, nil
+	})
+	return b
+}
+
 func TestService_Save(t *testing.T) {
 	t.Run("wraps any error from collaborating with action mapper", func(t *testing.T) {
 		service := newService().
@@ -361,6 +417,69 @@ func TestService_AddContributingCause(t *testing.T) {
 	})
 }
 
+func TestService_BindTrigger(t *testing.T) {
+	t.Run("when review doesn't exist it returns the error from the storage", func(t *testing.T) {
+		service := newService().
+			getReviewFail().
+			Build(t)
+		ctx := context.Background()
+
+		actual := service.BindTrigger(ctx, uuid.Nil, uuid.Nil, a.UnboundTrigger().Build())
+
+		require.Error(t, actual, "expected an error since we haven't stored any reviews")
+		require.ErrorContainsf(t, actual, "failed to get review:", "so we know we got the correct error")
+	})
+
+	t.Run("when the trigger isn't known return the error from it", func(t *testing.T) {
+		review := a.Review().Build()
+		service := newService().
+			getReview(review).
+			getTriggerFail().
+			Build(t)
+
+		actual := service.BindTrigger(
+			context.Background(),
+			review.ID,
+			uuid.Nil,
+			a.UnboundTrigger().Build(),
+		)
+
+		require.ErrorContains(t, actual, "failed to get trigger:")
+	})
+
+	t.Run("it returns any errors when adding the trigger to the review", func(t *testing.T) {
+		review := a.Review().Build()
+		unboundTrigger := a.UnboundTrigger().Build()
+		normalizedTrigger := a.NormalizedTrigger().Build()
+		service := newService().
+			getReview(review).
+			getTrigger(normalizedTrigger).
+			bindTriggerActionFail().
+			Build(t)
+
+		actual := service.BindTrigger(context.Background(), review.ID, normalizedTrigger.ID, unboundTrigger)
+
+		require.ErrorContains(t, actual, "failed binding trigger to review:")
+	})
+
+	t.Run("when both review and normalized triggers are known bind them", func(t *testing.T) {
+		review := a.Review().Build()
+		normalizedTrigger := a.NormalizedTrigger().Build()
+		unboundTrigger := a.UnboundTrigger().Build()
+		service := newService().
+			getReview(review).
+			getTrigger(normalizedTrigger).
+			bindTriggerAction(review, normalizedTrigger, unboundTrigger).
+			saveAction(review).
+			saveReview(review).
+			Build(t)
+		ctx := context.Background()
+
+		actual := service.BindTrigger(ctx, review.ID, normalizedTrigger.ID, unboundTrigger)
+		require.NoError(t, actual, "expected to have bound the cause to the review successfully")
+	})
+}
+
 func TestService_GetBoundContributingCause(t *testing.T) {
 	t.Run("when the review doesn't exist it returns an error", func(t *testing.T) {
 		service := newService().
@@ -385,7 +504,7 @@ func TestService_GetBoundContributingCause(t *testing.T) {
 
 	t.Run("when it's found it returns the reviewing.BoundCause", func(t *testing.T) {
 		boundCause := a.BoundCause().Build()
-		store := new(storageMock)
+		store := new(reviewStorageMock)
 		store.Test(t)
 		review := a.Review().WithContributingCause(boundCause).Build()
 		service := newService().
@@ -445,9 +564,12 @@ func TestService_UpdateBoundContributingCause(t *testing.T) {
 		service := newService().
 			getReview(review).
 			getCause(review.BoundCauses[0].Cause).
-			updateBoundContributingCauseAction(review, updatedCause). // doesn't update anything
-			saveAction(review).                                       // because it didn't update anything we just get the original passed in again
-			saveReview((func(r reviewing.Review) reviewing.Review {   // return something different to show that we're returning whatever is successfully saved
+			// doesn't update anything
+			// because it didn't update anything we just get the original passed in again
+			// return something different to show that we're returning whatever is successfully saved
+			updateBoundContributingCauseAction(review, updatedCause).
+			saveAction(review).
+			saveReview((func(r reviewing.Review) reviewing.Review {
 				r.BoundCauses[0] = updatedCause
 				return r
 			})(review)).
@@ -634,3 +756,19 @@ func TestReview_UpdateBoundContributingCause(t *testing.T) {
 		)
 	})
 }
+
+func TestReview_BindTrigger(t *testing.T) {
+	t.Run("adds the bound trigger to the list of bound triggers", func(t *testing.T) {
+		r := a.Review().Build()
+		bt := a.BoundTrigger().IsNotSaved().Build()
+
+		actual, err := r.BindTrigger(a.NormalizedTrigger().Build(), a.UnboundTrigger().Build())
+
+		require.NoError(t, err)
+		assert.NotEqual(t, bt.ID, actual.BoundTriggers[0].ID)
+		require.NotEmpty(t, actual.BoundTriggers[0].ID, "expected to have set the ID when binding, overwriting any existing IDs")
+
+		require.Equal(t, actual, a.Review().WithBoundTrigger(a.BoundTrigger().WithID(actual.BoundTriggers[0].ID).Build()).Build())
+		// when saving a valid trigger that hasn't been saved (i.e. it doesn't have an ID yet) it sets an id and then adds it to the list of bound triggers
+	})
+}
diff --git a/internal/reviewing/serviceactions.go b/internal/reviewing/serviceactions.go
index 1595409..da47f82 100644
--- a/internal/reviewing/serviceactions.go
+++ b/internal/reviewing/serviceactions.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
 	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing"
 	"github.com/gaqzi/incident-reviewer/internal/platform/action"
 	"github.com/gaqzi/incident-reviewer/internal/platform/validate"
@@ -33,6 +34,9 @@ func reviewServiceActions() *action.Mapper {
 
 		return r.updateTimestamps(), nil
 	})
+	m.Add("BindTrigger", func(r Review, t normalized.Trigger, ubt UnboundTrigger) (Review, error) {
+		return r.BindTrigger(t, ubt)
+	})
 
 	return m
 }
diff --git a/internal/reviewing/serviceactions_test.go b/internal/reviewing/serviceactions_test.go
index d08d638..63e1672 100644
--- a/internal/reviewing/serviceactions_test.go
+++ b/internal/reviewing/serviceactions_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
 
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
 	"github.com/gaqzi/incident-reviewer/internal/normalized/contributing"
 )
 
@@ -22,6 +23,7 @@ func TestActionMapper(t *testing.T) {
 				"BindContributingCause",
 				"UpdateBoundContributingCause",
 				"Save",
+				"BindTrigger",
 			},
 			mapper.All(),
 			"expected all causes to be listed here so we catch when we add new or remove one",
@@ -48,6 +50,26 @@ func TestActionMapper(t *testing.T) {
 		)
 	})
 
+	t.Run("BindTrigger sets the normalized.Trigger on the BoundTrigger before adding it to the Review and sets a valid ID if not provided", func(t *testing.T) {
+		mapper := reviewServiceActions()
+
+		doer, err := mapper.Get("BindTrigger")
+		require.NoError(t, err)
+		do, ok := doer.(func(Review, normalized.Trigger, UnboundTrigger) (Review, error))
+		require.True(t, ok)
+
+		trigger := normalized.Trigger{Name: "Something"}
+		review, err := do(Review{}, trigger, UnboundTrigger{Why: "a good reason"})
+		require.NoError(t, err)
+
+		require.Equal(
+			t,
+			Review{BoundTriggers: []BoundTrigger{{ID: review.BoundTriggers[0].ID, Trigger: trigger, UnboundTrigger: UnboundTrigger{Why: "a good reason"}}}},
+			review,
+			"expected the contributing trigger to have been set on the BoundTrigger and then added to the Review",
+		)
+	})
+
 	t.Run("Save validates and returns an error when it fails to validate", func(t *testing.T) {
 		mapper := reviewServiceActions()
 
diff --git a/test/a/normalizedtrigger.go b/test/a/normalizedtrigger.go
new file mode 100644
index 0000000..74b295c
--- /dev/null
+++ b/test/a/normalizedtrigger.go
@@ -0,0 +1,60 @@
+package a
+
+import (
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/gaqzi/incident-reviewer/internal/normalized"
+)
+
+type BuilderNormalizedTrigger struct {
+	t normalized.Trigger
+}
+
+func (b BuilderNormalizedTrigger) IsValid() BuilderNormalizedTrigger {
+	b.t.ID = uuid.MustParse("0193ddee-c2e6-72d6-ad36-9d4cee8a5e2f") // UUIDv7, just a value, no particular meaning
+	b.t.Name = "Third Party Outage"
+	b.t.Description = "When things go wrong for others"
+
+	return b
+}
+
+func (b BuilderNormalizedTrigger) IsSaved() BuilderNormalizedTrigger {
+	createdAt, err := time.Parse(time.RFC3339Nano, "2025-03-06T07:25:30.1337Z")
+	if err != nil {
+		panic("failed to parse example timestamp: " + err.Error())
+	}
+
+	b.t.CreatedAt = createdAt
+	b.t.UpdatedAt = createdAt
+
+	return b
+}
+
+func (b BuilderNormalizedTrigger) Build() normalized.Trigger {
+	return b.t
+}
+
+func (b BuilderNormalizedTrigger) IsNotSaved() BuilderNormalizedTrigger {
+	b.t.CreatedAt = time.Time{}
+	b.t.UpdatedAt = time.Time{}
+
+	return b
+}
+
+func (b BuilderNormalizedTrigger) WithID(uuid uuid.UUID) BuilderNormalizedTrigger {
+	b.t.ID = uuid
+	return b
+}
+
+func (b BuilderNormalizedTrigger) WithName(n string) BuilderNormalizedTrigger {
+	b.t.Name = n
+	return b
+}
+
+func NormalizedTrigger() BuilderNormalizedTrigger {
+	return BuilderNormalizedTrigger{}.
+		IsValid().
+		IsSaved()
+}
diff --git a/test/a/review.go b/test/a/review.go
index 43be693..f5f4284 100644
--- a/test/a/review.go
+++ b/test/a/review.go
@@ -109,6 +109,11 @@ func (b BuilderReview) WithContributingCause(rcs ...reviewing.BoundCause) Builde
 	return b
 }
 
+func (b BuilderReview) WithBoundTrigger(rt reviewing.BoundTrigger) BuilderReview {
+	b.r.BoundTriggers = append(b.r.BoundTriggers, rt)
+	return b
+}
+
 type BuilderBoundCause struct {
 	rc reviewing.BoundCause
 }
@@ -153,3 +158,51 @@ func (b BuilderBoundCause) WithIsProximalCause(tf bool) BuilderBoundCause {
 func (b BuilderBoundCause) Build() reviewing.BoundCause {
 	return b.rc
 }
+
+type BuilderBoundTrigger struct {
+	bt reviewing.BoundTrigger
+}
+
+func (b BuilderBoundTrigger) IsSaved() BuilderBoundTrigger {
+	b.bt.ID = uuid.MustParse("0193f6e0-a83b-71aa-a712-b0f7e0521108")
+	b.bt.UnboundTrigger = UnboundTrigger().Build()
+	b.bt.Trigger = NormalizedTrigger().Build()
+
+	return b
+}
+func (b BuilderBoundTrigger) IsNotSaved() BuilderBoundTrigger {
+	b.bt.ID = uuid.Nil
+
+	return b
+}
+
+func (b BuilderBoundTrigger) Build() reviewing.BoundTrigger {
+	return b.bt
+}
+
+func (b BuilderBoundTrigger) WithID(id uuid.UUID) BuilderBoundTrigger {
+	b.bt.ID = id
+	return b
+}
+
+func BoundTrigger() BuilderBoundTrigger {
+	return BuilderBoundTrigger{}.
+		IsSaved()
+}
+
+type BuilderUnboundTrigger struct {
+	but reviewing.UnboundTrigger
+}
+
+func (b BuilderUnboundTrigger) Build() reviewing.UnboundTrigger {
+	return b.but
+}
+
+func (b BuilderUnboundTrigger) WithWhy(why string) BuilderUnboundTrigger {
+	b.but.Why = why
+	return b
+}
+
+func UnboundTrigger() BuilderUnboundTrigger {
+	return BuilderUnboundTrigger{}.WithWhy("something")
+}
diff --git a/test/reviewing_test.go b/test/reviewing_test.go
index c523f4c..eace70f 100644
--- a/test/reviewing_test.go
+++ b/test/reviewing_test.go
@@ -188,6 +188,47 @@ func TestReviewing(t *testing.T) {
 		require.NoError(t, firstCause.Locator(`button.bind[type="submit"]`).Click())
 		require.NoError(t, assert.Locator(firstCause.Locator(".why")).ToContainText("I want to say something else now"))
 
+		// Add a trigger
+		triggerForm := page.Locator(`#triggers form.new`)
+		options, err = triggerForm.Locator(`[name="triggerID"] option`).All() // TODO: extract selecting an option into a helper
+		require.NoError(t, err)
+		require.GreaterOrEqual(t, len(options), 1)
+		for _, opt := range options {
+			innerTexts, err := opt.AllInnerTexts()
+			require.NoError(t, err)
+			for _, text := range innerTexts {
+				text = strings.TrimSpace(text)
+				if strings.HasPrefix(text, "Traffic increase") {
+					// Turns out, selecting by the value (at least when it's this long) breaks in firefox, so select it by the value instead of label.
+					chosenOption, err = opt.GetAttribute("value")
+					require.NoError(t, err, "failed to get value for option")
+					break
+				}
+			}
+		}
+		require.NotEmpty(t, chosenOption, "expected to have found a chosen option")
+		_, err = triggerForm.Locator(`[name="triggerID"]`).SelectOption(playwright.SelectOptionValues{Values: &[]string{chosenOption}})
+		require.NoError(t, err, "expected to have set the option")
+
+		require.NoError(t, triggerForm.Locator(`[name="why"]`).Fill("Marketing campaign started"))
+		require.NoError(t, triggerForm.Locator(`[type="submit"]`).Click())
+
+		// After adding the trigger let's see it in the list
+		triggerListing := page.Locator(`#triggers ul.listing`)
+		require.NoError(
+			t,
+			assert.Locator(triggerListing.Locator(`li`)).ToHaveCount(1),
+			"expected to have one item listed after creating it",
+		)
+
+		firstTrigger, err := triggerListing.Locator("li").First().InnerText()
+		require.NoError(t, err, "failed to get the text of the first trigger")
+		require.Equal(
+			t,
+			"Traffic increase -- Marketing campaign started",
+			firstTrigger,
+		)
+
 		require.NoError(t, pw.Stop(), "failed to stop playwright")
 	})
 }


Reviews:
user: login: gaqzi
body: please use a [rebase workflow](https://www.atlassian.com/git/tutorials/merging-vs-rebasing) instead of merging in, it creates a very messy history to merge in the changes and it encourages you to squash merge, which looses how the work happens. :)

This branch has a very different look from the rest of the repo thanks to all these merges.
![incident-reviewer](https://github.com/user-attachments/assets/6af21011-1027-4d4b-92f1-5e29878a3e06)

Tbh, I no longer remember why I prefer the rebase flow, I think it was partly about `git bisect` being more obvious because it just jumps up and down a straight linear history, but also because it gives a nice fake view of how the work happened, whatever was on a branch was built on top of main, even if it rebased from main a couple of times to do so. When it finally gets merged all the commits will look as if they were just a clean application on top of what went in.

It's very much a preference rather than an absolute "this is the best option." :)

Similarly, please don't do `git add .` or similar when staging changes, I removed a couple of stray yaml files with Grafana alert definitions that had accidentally made it in. :)
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-20T14:42:49Z

Review comments:
diff_hunk: @@ -0,0 +1,23 @@
+<section id="triggers" hx-target="this" hx-swap="outerHTML">
path: internal/app/web/templates/reviews/show/_triggers.html
user: login: gaqzi
body: set the `hx-target` so htmx knows that this is the place to replace when we're returning a partial from the server when adding a new bound trigger
created_at: 2025-04-20T14:34:58Z
author_association: OWNER
diff_hunk: @@ -188,6 +188,47 @@ func TestReviewing(t *testing.T) {
 		require.NoError(t, firstCause.Locator(`button.bind[type="submit"]`).Click())
 		require.NoError(t, assert.Locator(firstCause.Locator(".why")).ToContainText("I want to say something else now"))
 
+		// Add a trigger
+		triggerForm := page.Locator(`#triggers form.new`)
+		options, err = triggerForm.Locator(`[name="triggerID"] option`).All() // TODO: extract selecting an option into a helper
+		require.NoError(t, err)
+		require.GreaterOrEqual(t, len(options), 1)
+		for _, opt := range options {
+			innerTexts, err := opt.AllInnerTexts()
+			require.NoError(t, err)
+			for _, text := range innerTexts {
+				text = strings.TrimSpace(text)
+				if strings.HasPrefix(text, "Traffic increase") {
+					// Turns out, selecting by the value (at least when it's this long) breaks in firefox, so select it by the value instead of label.
+					chosenOption, err = opt.GetAttribute("value")
+					require.NoError(t, err, "failed to get value for option")
+					break
+				}
+			}
+		}
+		require.NotEmpty(t, chosenOption, "expected to have found a chosen option")
+		_, err = triggerForm.Locator(`[name="triggerID"]`).SelectOption(playwright.SelectOptionValues{Values: &[]string{chosenOption}})
+		require.NoError(t, err, "expected to have set the option")
+
+		require.NoError(t, triggerForm.Locator(`[name="why"]`).Fill("Marketing campaign started"))
+		require.NoError(t, triggerForm.Locator(`[type="submit"]`).Click())
+
+		// After adding the trigger let's see it in the list
+		triggerListing := page.Locator(`#triggers ul.listing`)
+		require.NoError(
+			t,
+			assert.Locator(triggerListing.Locator(`li`)).ToHaveCount(1),
path: test/reviewing_test.go
user: login: gaqzi
body: use `ToHaveCount` so it will try a couple of times to see if the page has changed under us, because playwright is so fast that it often starts checking the page before htmx has had a chance to get the response back from the backend
created_at: 2025-04-20T14:36:00Z
author_association: OWNER

PR comments:
user: login: jainoopur
created_at: 2025-04-21T03:28:23Z
body: >Similarly, please don't do git add . or similar when staging changes, I removed a couple of stray yaml files with Grafana alert definitions that had accidentally made it in. :)

Those sneaked in through the [mob commit](https://github.com/gaqzi/incident-reviewer/pull/67/commits/d8355860da3cd7991c3bdb2f1302b724b30dafe2#diff-ff8f08656943860f26cd5e82b31bddb50f9edf6b8d372aaccf54d9602e7d50c2) 🫢 didn't remove because I thought they were related, thanks for cleaning up!
author_association: COLLABORATOR
user: login: gaqzi
created_at: 2025-04-21T10:29:25Z
body: Aaah, if so, then they probably came from me because I was looking at the alerts at one point 😅

D’oh, the blame pointed back at me 😁

On Mon, Apr 21, 2025, at 11:28, Noopur Jain wrote:
>> Similarly, please don't do git add . or similar when staging changes, I removed a couple of stray yaml files with Grafana alert definitions that had accidentally made it in. :)
>> 
> Those sneaked in through the mob commit <https://github.com/gaqzi/incident-reviewer/pull/67/commits/d8355860da3cd7991c3bdb2f1302b724b30dafe2#diff-ff8f08656943860f26cd5e82b31bddb50f9edf6b8d372aaccf54d9602e7d50c2> 🫢 didn't remove because I thought they were related, thanks for cleaning up!
> 
> 
> —
> Reply to this email directly, view it on GitHub <https://github.com/gaqzi/incident-reviewer/pull/69#issuecomment-2817554690>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAAEN55TP4SGYKGJOTPYZY322RQW3AVCNFSM6AAAAABZM76EB2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDQMJXGU2TINRZGA>.
> You are receiving this because you modified the open/close state.Message ID: ***@***.***>
> 
> *jainoopur* left a comment (gaqzi/incident-reviewer#69) <https://github.com/gaqzi/incident-reviewer/pull/69#issuecomment-2817554690>
>> Similarly, please don't do git add . or similar when staging changes, I removed a couple of stray yaml files with Grafana alert definitions that had accidentally made it in. :)
>> 
> Those sneaked in through the mob commit <https://github.com/gaqzi/incident-reviewer/pull/67/commits/d8355860da3cd7991c3bdb2f1302b724b30dafe2#diff-ff8f08656943860f26cd5e82b31bddb50f9edf6b8d372aaccf54d9602e7d50c2> 🫢 didn't remove because I thought they were related, thanks for cleaning up!
> 
> 
> —
> Reply to this email directly, view it on GitHub <https://github.com/gaqzi/incident-reviewer/pull/69#issuecomment-2817554690>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAAEN55TP4SGYKGJOTPYZY322RQW3AVCNFSM6AAAAABZM76EB2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDQMJXGU2TINRZGA>.
> You are receiving this because you modified the open/close state.Message ID: ***@***.***>
> 

author_association: OWNER