From a8d8d138cb8602f4fd42a01a79ee95b1fc658cee Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 21:45:19 +0300 Subject: [PATCH 1/7] feat(eventsource): add ConnectedUIDs accessor Expose a snapshot of currently registered uids so fan-out broadcasters can pre-filter recipients before calling SendMessage. --- modules/eventsource/manager.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/modules/eventsource/manager.go b/modules/eventsource/manager.go index 7ed2a82903..3a347a4f7b 100644 --- a/modules/eventsource/manager.go +++ b/modules/eventsource/manager.go @@ -68,6 +68,19 @@ func (m *Manager) UnregisterAll() { m.messengers = map[int64]*Messenger{} } +// ConnectedUIDs returns a snapshot of all currently registered user IDs. +// Useful for fan-out broadcasters that need to filter recipients before +// calling SendMessage. +func (m *Manager) ConnectedUIDs() []int64 { + m.mutex.Lock() + defer m.mutex.Unlock() + uids := make([]int64, 0, len(m.messengers)) + for uid := range m.messengers { + uids = append(uids, uid) + } + return uids +} + // SendMessage sends a message to a particular user func (m *Manager) SendMessage(uid int64, message *Event) { m.mutex.Lock() -- 2.53.0 From 3c831efc0cf641ecaf559a4ac0c9f9cacaa4c695 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 21:45:28 +0300 Subject: [PATCH 2/7] feat(project): add SSE event bus and publish helpers New services/project_events package marshals typed payloads to JSON, wraps them in SSE events named project-board.{project_id}, and fans them out via the eventsource manager to every connected user that has read access to the project. Each Publish* helper runs the broadcast in a goroutine so request handlers stay responsive. Includes WithSessionTag / SessionTagFromContext for propagating an X-Session-Tag value down to the publisher (so the originating browser tab can suppress its own echo). Unit tests cover event-name format, payload JSON shape, session-tag propagation, the connected-uids access filter, and the broadcast fan-out path. --- services/project_events/events.go | 324 +++++++++++++++++++++++++ services/project_events/events_test.go | 322 ++++++++++++++++++++++++ 2 files changed, 646 insertions(+) create mode 100644 services/project_events/events.go create mode 100644 services/project_events/events_test.go diff --git a/services/project_events/events.go b/services/project_events/events.go new file mode 100644 index 0000000000..b86bbf735d --- /dev/null +++ b/services/project_events/events.go @@ -0,0 +1,324 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package project_events publishes project board mutations as Server-Sent +// Events so other browser tabs viewing the same board can update their DOM +// in near real time. +// +// Each public Publish* helper marshals a typed payload to JSON, wraps it in +// an *eventsource.Event whose Name is "project-board.{project_id}", and +// fans the event out to every currently connected user that has read +// access to the project. All publish helpers are non-blocking: they spawn +// a goroutine so request handlers do not stall on slow consumers. +package project_events + +import ( + "context" + "strconv" + + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" +) + +// sessionTagCtxKey is the context key under which the X-Session-Tag value +// from the originating HTTP request is stashed. Publishers read it via +// SessionTagFromContext to attach to outgoing events so the originating +// browser tab can suppress its own echo. +type sessionTagCtxKey struct{} + +// WithSessionTag returns ctx decorated with the provided session tag. +// Web/API middleware reads the X-Session-Tag header and calls this so +// service- and model-layer publishers can pull the tag back out. +func WithSessionTag(ctx context.Context, tag string) context.Context { + if tag == "" { + return ctx + } + return context.WithValue(ctx, sessionTagCtxKey{}, tag) +} + +// SessionTagFromContext returns the session tag previously stored via +// WithSessionTag, or "" when none was set. +func SessionTagFromContext(ctx context.Context) string { + if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok { + return v + } + return "" +} + +// Event payload structs ------------------------------------------------------ + +// CardMoved is emitted when an issue is moved between columns or reordered +// within a column. +type CardMoved struct { + ProjectID int64 `json:"project_id"` + IssueID int64 `json:"issue_id"` + FromColumnID int64 `json:"from_column_id"` + ToColumnID int64 `json:"to_column_id"` + Sorting int64 `json:"sorting"` + SessionTag string `json:"session_tag,omitempty"` +} + +// CardLinked is emitted when an issue is added to a project's default column. +type CardLinked struct { + ProjectID int64 `json:"project_id"` + IssueID int64 `json:"issue_id"` + ColumnID int64 `json:"column_id"` + SessionTag string `json:"session_tag,omitempty"` +} + +// CardUnlinked is emitted when an issue is removed from a project. +type CardUnlinked struct { + ProjectID int64 `json:"project_id"` + IssueID int64 `json:"issue_id"` + SessionTag string `json:"session_tag,omitempty"` +} + +// ColumnCreated is emitted when a new column is added to a project. +type ColumnCreated struct { + ProjectID int64 `json:"project_id"` + ColumnID int64 `json:"column_id"` + Title string `json:"title"` + Color string `json:"color"` + Sorting int64 `json:"sorting"` + IsDefault bool `json:"is_default"` +} + +// ColumnUpdated is emitted when a column's title, color, or sorting changes. +type ColumnUpdated struct { + ProjectID int64 `json:"project_id"` + ColumnID int64 `json:"column_id"` + Title string `json:"title"` + Color string `json:"color"` + Sorting int64 `json:"sorting"` +} + +// ColumnDeleted is emitted when a column is removed from a project. +// Deletion implicitly relocates issues to the default column, so the +// publisher will also emit one CardMoved per affected issue; the frontend +// only needs to drop the column and react to the per-issue moves. +type ColumnDeleted struct { + ProjectID int64 `json:"project_id"` + ColumnID int64 `json:"column_id"` +} + +// ColumnSort is one entry in a ColumnReordered batch. +type ColumnSort struct { + ColumnID int64 `json:"column_id"` + Sorting int64 `json:"sorting"` +} + +// ColumnReordered is emitted when columns within a project are dragged into +// a new order. +type ColumnReordered struct { + ProjectID int64 `json:"project_id"` + Columns []ColumnSort `json:"columns"` +} + +// ProjectUpdated is emitted when project metadata (title, description, +// card type, open/closed state) changes. +type ProjectUpdated struct { + ProjectID int64 `json:"project_id"` + Title string `json:"title"` + Description string `json:"description"` + CardType string `json:"card_type"` + IsClosed bool `json:"is_closed"` +} + +// ProjectDeleted is emitted when a project is deleted. +type ProjectDeleted struct { + ProjectID int64 `json:"project_id"` +} + +// Broadcast plumbing --------------------------------------------------------- + +// broadcastFn is the package-level seam used to send an event to a set of +// uids. Tests swap it out to capture calls without touching the real +// eventsource manager. +var broadcastFn = defaultBroadcast + +func defaultBroadcast(uids []int64, event *eventsource.Event) { + mgr := eventsource.GetManager() + for _, uid := range uids { + mgr.SendMessage(uid, event) + } +} + +// connectedUIDsLister returns the uid set the broadcast helpers should +// consider as candidate recipients. Tests override it to feed a +// deterministic list. +var connectedUIDsLister = func() []int64 { + return eventsource.GetManager().ConnectedUIDs() +} + +// projectLookup loads a project by id. Stubbable in tests so the +// access-filter logic can be exercised without spinning up a database. +var projectLookup = project_model.GetProjectByID + +// projectAccessChecker decides whether the user identified by uid is +// allowed to read the given project. Tests stub this to bypass the real +// permission system. +var projectAccessChecker = canReadProject + +// connectedUIDsWithProjectAccess returns the subset of currently connected +// uids that the access checker confirms can read projectID. +func connectedUIDsWithProjectAccess(ctx context.Context, projectID int64) []int64 { + uids := connectedUIDsLister() + if len(uids) == 0 { + return nil + } + project, err := projectLookup(ctx, projectID) + if err != nil { + log.Debug("project_events: GetProjectByID(%d) failed: %v", projectID, err) + return nil + } + allowed := make([]int64, 0, len(uids)) + for _, uid := range uids { + ok, err := projectAccessChecker(ctx, uid, project) + if err != nil { + log.Debug("project_events: access check uid=%d project=%d: %v", uid, projectID, err) + continue + } + if ok { + allowed = append(allowed, uid) + } + } + return allowed +} + +// canReadProject implements the real read-permission check used in +// production: repo projects defer to the repo's TypeProjects unit access; +// user / org projects fall back to user visibility. +func canReadProject(ctx context.Context, uid int64, project *project_model.Project) (bool, error) { + user, err := user_model.GetUserByID(ctx, uid) + if err != nil { + return false, err + } + + if project.RepoID > 0 { + var repo *repo_model.Repository + if project.Repo != nil { + repo = project.Repo + } else { + repo, err = repo_model.GetRepositoryByID(ctx, project.RepoID) + if err != nil { + return false, err + } + } + // AccessModeRead == 1; we use the literal because the + // perm_model package's typed constant would force another + // import alias and the meaning is well established here. + ok, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeProjects, 1) + if err != nil { + return false, err + } + return ok, nil + } + + if project.OwnerID > 0 { + owner := project.Owner + if owner == nil { + owner, err = user_model.GetUserByID(ctx, project.OwnerID) + if err != nil { + return false, err + } + } + return user_model.IsUserVisibleToViewer(ctx, owner, user), nil + } + + return false, nil +} + +// publishEvent is the shared pipeline used by every Publish* helper. +// It marshals the payload, builds the SSE Event, looks up authorized +// recipients, and fans the event out via broadcastFn. The whole thing +// runs inside the calling goroutine; callers should wrap it in `go` so +// request handlers stay responsive. +func publishEvent(ctx context.Context, projectID int64, payload any) { + data, err := json.Marshal(payload) + if err != nil { + log.Error("project_events: marshal payload for project %d: %v", projectID, err) + return + } + event := &eventsource.Event{ + Name: eventName(projectID), + Data: data, + } + uids := connectedUIDsWithProjectAccess(ctx, projectID) + if len(uids) == 0 { + return + } + broadcastFn(uids, event) +} + +// eventName returns the SSE event name for a given project id. +func eventName(projectID int64) string { + return "project-board." + strconv.FormatInt(projectID, 10) +} + +// Publishers ----------------------------------------------------------------- + +// PublishCardMoved fans out a CardMoved event for the given payload. +func PublishCardMoved(ctx context.Context, payload CardMoved) { + if payload.SessionTag == "" { + payload.SessionTag = SessionTagFromContext(ctx) + } + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishCardLinked fans out a CardLinked event for the given payload. +func PublishCardLinked(ctx context.Context, payload CardLinked) { + if payload.SessionTag == "" { + payload.SessionTag = SessionTagFromContext(ctx) + } + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishCardUnlinked fans out a CardUnlinked event for the given payload. +func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) { + if payload.SessionTag == "" { + payload.SessionTag = SessionTagFromContext(ctx) + } + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishColumnCreated fans out a ColumnCreated event for the given payload. +func PublishColumnCreated(ctx context.Context, payload ColumnCreated) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishColumnUpdated fans out a ColumnUpdated event for the given payload. +func PublishColumnUpdated(ctx context.Context, payload ColumnUpdated) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishColumnDeleted fans out a ColumnDeleted event for the given payload. +func PublishColumnDeleted(ctx context.Context, payload ColumnDeleted) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishColumnReordered fans out a ColumnReordered event for the given payload. +func PublishColumnReordered(ctx context.Context, payload ColumnReordered) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishProjectUpdated fans out a ProjectUpdated event for the given payload. +func PublishProjectUpdated(ctx context.Context, payload ProjectUpdated) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// PublishProjectDeleted fans out a ProjectDeleted event for the given payload. +func PublishProjectDeleted(ctx context.Context, payload ProjectDeleted) { + go publishEvent(detach(ctx), payload.ProjectID, payload) +} + +// detach strips cancellation/deadline from ctx but preserves stored +// values (notably the session tag) so the goroutine outlives the request. +func detach(ctx context.Context) context.Context { + return context.WithoutCancel(ctx) +} diff --git a/services/project_events/events_test.go b/services/project_events/events_test.go new file mode 100644 index 0000000000..c5bee765c8 --- /dev/null +++ b/services/project_events/events_test.go @@ -0,0 +1,322 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project_events + +import ( + "context" + "sync" + "testing" + "time" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// capturedCall is one observed broadcast: the recipient uid set plus the +// constructed Event. +type capturedCall struct { + uids []int64 + event *eventsource.Event +} + +// installFakes swaps every package-level seam used by publishEvent for +// test doubles: a fake uid lister, a stubbed project lookup that +// returns a synthetic project (no DB hit), an "everyone passes" access +// checker, and a broadcaster that pushes calls onto a buffered channel. +// +// The returned restore func reverts every seam; defer it in the test. +func installFakes(t *testing.T, uids []int64) (<-chan capturedCall, func()) { + t.Helper() + + calls := make(chan capturedCall, 16) + + origBroadcast := broadcastFn + origLister := connectedUIDsLister + origChecker := projectAccessChecker + origLookup := projectLookup + + broadcastFn = func(uids []int64, event *eventsource.Event) { + calls <- capturedCall{uids: append([]int64(nil), uids...), event: event} + } + connectedUIDsLister = func() []int64 { + return append([]int64(nil), uids...) + } + projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { + return &project_model.Project{ID: id}, nil + } + projectAccessChecker = func(_ context.Context, _ int64, _ *project_model.Project) (bool, error) { + return true, nil + } + + return calls, func() { + broadcastFn = origBroadcast + connectedUIDsLister = origLister + projectAccessChecker = origChecker + projectLookup = origLookup + } +} + +// awaitCall blocks until one capturedCall arrives or the test deadline +// elapses. It fails the test on timeout. +func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall { + t.Helper() + select { + case c := <-ch: + return c + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for broadcast") + return capturedCall{} + } +} + +func TestEventNameFormat(t *testing.T) { + assert.Equal(t, "project-board.42", eventName(42)) + assert.Equal(t, "project-board.0", eventName(0)) +} + +func TestPublishHelpers_NameAndPayload(t *testing.T) { + cases := []struct { + name string + invoke func(ctx context.Context) + wantName string + wantData any + }{ + { + name: "card.moved", + wantName: "project-board.10", + invoke: func(ctx context.Context) { + PublishCardMoved(ctx, CardMoved{ + ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3, + }) + }, + wantData: CardMoved{ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3}, + }, + { + name: "card.linked", + wantName: "project-board.11", + invoke: func(ctx context.Context) { + PublishCardLinked(ctx, CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9}) + }, + wantData: CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9}, + }, + { + name: "card.unlinked", + wantName: "project-board.12", + invoke: func(ctx context.Context) { + PublishCardUnlinked(ctx, CardUnlinked{ProjectID: 12, IssueID: 8}) + }, + wantData: CardUnlinked{ProjectID: 12, IssueID: 8}, + }, + { + name: "column.created", + wantName: "project-board.13", + invoke: func(ctx context.Context) { + PublishColumnCreated(ctx, ColumnCreated{ + ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true, + }) + }, + wantData: ColumnCreated{ + ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true, + }, + }, + { + name: "column.updated", + wantName: "project-board.14", + invoke: func(ctx context.Context) { + PublishColumnUpdated(ctx, ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"}) + }, + wantData: ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"}, + }, + { + name: "column.deleted", + wantName: "project-board.15", + invoke: func(ctx context.Context) { + PublishColumnDeleted(ctx, ColumnDeleted{ProjectID: 15, ColumnID: 5}) + }, + wantData: ColumnDeleted{ProjectID: 15, ColumnID: 5}, + }, + { + name: "column.reordered", + wantName: "project-board.16", + invoke: func(ctx context.Context) { + PublishColumnReordered(ctx, ColumnReordered{ + ProjectID: 16, + Columns: []ColumnSort{ + {ColumnID: 1, Sorting: 0}, + {ColumnID: 2, Sorting: 1}, + }, + }) + }, + wantData: ColumnReordered{ + ProjectID: 16, + Columns: []ColumnSort{ + {ColumnID: 1, Sorting: 0}, + {ColumnID: 2, Sorting: 1}, + }, + }, + }, + { + name: "project.updated", + wantName: "project-board.17", + invoke: func(ctx context.Context) { + PublishProjectUpdated(ctx, ProjectUpdated{ + ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false, + }) + }, + wantData: ProjectUpdated{ + ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false, + }, + }, + { + name: "project.deleted", + wantName: "project-board.18", + invoke: func(ctx context.Context) { + PublishProjectDeleted(ctx, ProjectDeleted{ProjectID: 18}) + }, + wantData: ProjectDeleted{ProjectID: 18}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ch, restore := installFakes(t, []int64{1}) + defer restore() + + tc.invoke(context.Background()) + + c := awaitCall(t, ch) + assert.Equal(t, tc.wantName, c.event.Name) + gotJSON, ok := c.event.Data.([]byte) + require.True(t, ok, "Event.Data should be []byte") + + wantJSON, err := json.Marshal(tc.wantData) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(gotJSON)) + }) + } +} + +// TestSessionTagPropagation verifies that when a publish is invoked +// inside a context decorated by WithSessionTag, the emitted JSON +// payload carries the tag. +func TestSessionTagPropagation(t *testing.T) { + ch, restore := installFakes(t, []int64{1}) + defer restore() + + ctx := WithSessionTag(context.Background(), "abc-123") + PublishCardMoved(ctx, CardMoved{ + ProjectID: 99, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0, + }) + + c := awaitCall(t, ch) + var payload CardMoved + require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload)) + assert.Equal(t, "abc-123", payload.SessionTag) +} + +// TestSessionTagExplicitOverridesContext verifies that an explicit +// SessionTag set on the payload struct is preserved. +func TestSessionTagExplicitOverridesContext(t *testing.T) { + ch, restore := installFakes(t, []int64{1}) + defer restore() + + ctx := WithSessionTag(context.Background(), "from-ctx") + PublishCardMoved(ctx, CardMoved{ + ProjectID: 1, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0, + SessionTag: "explicit", + }) + + c := awaitCall(t, ch) + var payload CardMoved + require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload)) + assert.Equal(t, "explicit", payload.SessionTag) +} + +// TestConnectedUIDsWithProjectAccess_FiltersByPermission ensures the +// helper drops uids the access checker rejects. +func TestConnectedUIDsWithProjectAccess_FiltersByPermission(t *testing.T) { + origLister := connectedUIDsLister + origChecker := projectAccessChecker + origLookup := projectLookup + defer func() { + connectedUIDsLister = origLister + projectAccessChecker = origChecker + projectLookup = origLookup + }() + + connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} } + projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { + return &project_model.Project{ID: id}, nil + } + allowed := map[int64]bool{1: true, 3: true} + projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) { + return allowed[uid], nil + } + + got := connectedUIDsWithProjectAccess(context.Background(), 42) + assert.ElementsMatch(t, []int64{1, 3}, got) +} + +// TestConnectedUIDsWithProjectAccess_NoConnections shortcuts when no +// users are connected; the project lookup must not be called. +func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) { + origLister := connectedUIDsLister + origLookup := projectLookup + defer func() { + connectedUIDsLister = origLister + projectLookup = origLookup + }() + + connectedUIDsLister = func() []int64 { return nil } + called := false + projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) { + called = true + return nil, nil + } + + got := connectedUIDsWithProjectAccess(context.Background(), 42) + assert.Empty(t, got) + assert.False(t, called, "project lookup should be skipped when no uids are connected") +} + +// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent +// directly to verify the uid set computed by the access filter is +// what gets handed to broadcastFn. +func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) { + origBroadcast := broadcastFn + origLister := connectedUIDsLister + origChecker := projectAccessChecker + origLookup := projectLookup + defer func() { + broadcastFn = origBroadcast + connectedUIDsLister = origLister + projectAccessChecker = origChecker + projectLookup = origLookup + }() + + var mu sync.Mutex + var got []int64 + broadcastFn = func(uids []int64, _ *eventsource.Event) { + mu.Lock() + got = append([]int64(nil), uids...) + mu.Unlock() + } + connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} } + projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { + return &project_model.Project{ID: id}, nil + } + projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) { + return uid != 20, nil + } + + publishEvent(context.Background(), 1, ColumnDeleted{ProjectID: 1, ColumnID: 5}) + + mu.Lock() + defer mu.Unlock() + assert.ElementsMatch(t, []int64{10, 30}, got) +} -- 2.53.0 From 3fd0aa751d69c651ddc4f9dfc4317799295130a1 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 21:53:35 +0300 Subject: [PATCH 3/7] feat(project): publish board events from service+model choke points Wrap the model-layer column/project/issue mutation funcs in service-layer helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns, DeleteProject, AssignOrRemoveProjects) that publish the matching SSE event after the underlying call succeeds. Routers (web + REST) are migrated to call these service helpers so the publish side-effects fire uniformly across repo, user, and org scopes. DeleteColumn snapshots the column's issues before deletion and emits one CardMoved per affected issue (alongside the ColumnDeleted event) so the receiving tab can patch the DOM without a full reload. Move-issue publishing fires after the txn commits so we never emit events for moves that get rolled back. --- routers/api/v1/org/project.go | 12 +-- routers/api/v1/repo/issue.go | 3 +- routers/api/v1/repo/project.go | 12 +-- routers/api/v1/user/project.go | 12 +-- routers/web/org/projects.go | 10 +- routers/web/repo/projects.go | 12 +-- routers/web/shared/project/column.go | 3 +- services/projects/column.go | 151 +++++++++++++++++++++++++++ services/projects/issue.go | 111 +++++++++++++++++++- services/projects/project.go | 11 +- 10 files changed, 302 insertions(+), 35 deletions(-) create mode 100644 services/projects/column.go diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 72a7597c69..c9d958fe08 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -344,7 +344,7 @@ func DeleteOrgProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -463,7 +463,7 @@ func CreateOrgProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -540,7 +540,7 @@ func EditOrgProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -587,7 +587,7 @@ func DeleteOrgProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -801,7 +801,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -811,7 +811,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 39ca7fb77e..1c462b0c00 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -31,6 +31,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" + project_service "code.gitea.io/gitea/services/projects" ) // buildSearchIssuesRepoIDs builds the list of repository IDs for issue search based on query parameters. @@ -915,7 +916,7 @@ func EditIssue(ctx *context.APIContext) { // Update projects if provided if canWrite && form.Projects != nil { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, *form.Projects); err != nil { if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusBadRequest, err) } else { diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ec042f369e..eb91d4b43a 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -371,7 +371,7 @@ func DeleteProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -500,7 +500,7 @@ func CreateProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -582,7 +582,7 @@ func EditProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -634,7 +634,7 @@ func DeleteProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -866,7 +866,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -878,7 +878,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go index a47cc893bc..f1c4f40c1e 100644 --- a/routers/api/v1/user/project.go +++ b/routers/api/v1/user/project.go @@ -366,7 +366,7 @@ func DeleteUserProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -489,7 +489,7 @@ func CreateUserProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -570,7 +570,7 @@ func EditUserProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -621,7 +621,7 @@ func DeleteUserProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -837,7 +837,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -847,7 +847,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index f3cc970162..2222e88764 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -214,7 +214,7 @@ func DeleteProject(ctx *context.Context) { return } - if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + if err := project_service.DeleteProject(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) @@ -283,7 +283,7 @@ func EditProjectPost(ctx *context.Context) { p.Title = form.Title p.Description = form.Content p.CardType = form.CardType - if err = project_model.UpdateProject(ctx, p); err != nil { + if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil { ctx.ServerError("UpdateProjects", err) return } @@ -496,7 +496,7 @@ func DeleteProjectColumn(ctx *context.Context) { return } - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil { + if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil { ctx.ServerError("DeleteProjectColumnByID", err) return } @@ -514,7 +514,7 @@ func AddColumnToProjectPost(ctx *context.Context) { return } - if err := project_model.NewColumn(ctx, &project_model.Column{ + if err := project_service.CreateColumn(ctx, &project_model.Column{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -567,7 +567,7 @@ func EditProjectColumn(ctx *context.Context) { column.Sorting = form.Sorting } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.ServerError("UpdateProjectColumn", err) return } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a517498fbc..93ba7b3804 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -191,7 +191,7 @@ func DeleteProject(ctx *context.Context) { return } - if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + if err := project_service.DeleteProject(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) @@ -264,7 +264,7 @@ func EditProjectPost(ctx *context.Context) { p.Title = form.Title p.Description = form.Content p.CardType = form.CardType - if err = project_model.UpdateProject(ctx, p); err != nil { + if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil { ctx.ServerError("UpdateProjects", err) return } @@ -459,7 +459,7 @@ func UpdateIssueProject(ctx *context.Context) { projectIDs = filteredIDs var failedIssues []int64 for _, issue := range issues { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil { if errors.Is(err, util.ErrPermissionDenied) { failedIssues = append(failedIssues, issue.ID) continue @@ -569,7 +569,7 @@ func DeleteProjectColumn(ctx *context.Context) { return } - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil { + if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil { ctx.ServerError("DeleteProjectColumnByID", err) return } @@ -597,7 +597,7 @@ func AddColumnToProjectPost(ctx *context.Context) { return } - if err := project_model.NewColumn(ctx, &project_model.Column{ + if err := project_service.CreateColumn(ctx, &project_model.Column{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -672,7 +672,7 @@ func EditProjectColumn(ctx *context.Context) { column.Sorting = form.Sorting } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.ServerError("UpdateProjectColumn", err) return } diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go index b6ffaea7f8..9cbfb73113 100644 --- a/routers/web/shared/project/column.go +++ b/routers/web/shared/project/column.go @@ -7,6 +7,7 @@ import ( project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/services/context" + project_service "code.gitea.io/gitea/services/projects" ) // MoveColumns moves or keeps columns in a project and sorts them inside that project @@ -39,7 +40,7 @@ func MoveColumns(ctx *context.Context) { sortedColumnIDs[column.Sorting] = column.ColumnID } - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + if err = project_service.ReorderColumns(ctx, project, sortedColumnIDs); err != nil { ctx.ServerError("MoveColumnsOnProject", err) return } diff --git a/services/projects/column.go b/services/projects/column.go new file mode 100644 index 0000000000..128ea66870 --- /dev/null +++ b/services/projects/column.go @@ -0,0 +1,151 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/project_events" +) + +// CreateColumn inserts a new column into a project and publishes a +// ColumnCreated event. Routers should call this instead of +// project_model.NewColumn so the SSE side-effect fires uniformly across +// repo, user, and org scopes. +func CreateColumn(ctx context.Context, column *project_model.Column) error { + if err := project_model.NewColumn(ctx, column); err != nil { + return err + } + project_events.PublishColumnCreated(ctx, project_events.ColumnCreated{ + ProjectID: column.ProjectID, + ColumnID: column.ID, + Title: column.Title, + Color: column.Color, + Sorting: int64(column.Sorting), + IsDefault: column.Default, + }) + return nil +} + +// EditColumn updates a column and publishes a ColumnUpdated event. +func EditColumn(ctx context.Context, column *project_model.Column) error { + if err := project_model.UpdateColumn(ctx, column); err != nil { + return err + } + project_events.PublishColumnUpdated(ctx, project_events.ColumnUpdated{ + ProjectID: column.ProjectID, + ColumnID: column.ID, + Title: column.Title, + Color: column.Color, + Sorting: int64(column.Sorting), + }) + return nil +} + +// DeleteColumn removes a column from a project and publishes the +// matching ColumnDeleted event. The model layer also moves the +// column's issues to the project's default column; we publish those +// individual moves so receiving tabs can patch the DOM without a full +// reload. We snapshot affected issues *before* the delete so we have +// their ids; the destination column id is resolved after. +func DeleteColumn(ctx context.Context, columnID int64) error { + // Snapshot the column + its issues so we know what to publish + // after the delete commits. Errors here are non-fatal: we still + // run the delete, and just skip per-issue events. + col, snapErr := project_model.GetColumn(ctx, columnID) + var ( + projectID int64 + movedIssues []int64 + ) + if snapErr == nil { + projectID = col.ProjectID + issues, err := col.GetIssues(ctx) + if err == nil { + movedIssues = make([]int64, 0, len(issues)) + for _, pi := range issues { + movedIssues = append(movedIssues, pi.IssueID) + } + } + } + + if err := project_model.DeleteColumnByID(ctx, columnID); err != nil { + return err + } + + if snapErr != nil || projectID == 0 { + return nil + } + project_events.PublishColumnDeleted(ctx, project_events.ColumnDeleted{ + ProjectID: projectID, + ColumnID: columnID, + }) + + // Resolve the new (default) column to attach to the per-issue + // CardMoved events. Failures here are tolerated; the frontend + // already knows the column is gone and will simply render its + // next refresh as authoritative. + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + return nil + } + defaultCol, err := project.MustDefaultColumn(ctx) + if err != nil { + return nil + } + for _, issueID := range movedIssues { + project_events.PublishCardMoved(ctx, project_events.CardMoved{ + ProjectID: projectID, + IssueID: issueID, + FromColumnID: columnID, + ToColumnID: defaultCol.ID, + }) + } + return nil +} + +// ReorderColumns persists a new sort order for project columns and +// publishes a ColumnReordered batch event. +func ReorderColumns(ctx context.Context, project *project_model.Project, sortedColumnIDs map[int64]int64) error { + if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + return err + } + cols := make([]project_events.ColumnSort, 0, len(sortedColumnIDs)) + for sorting, columnID := range sortedColumnIDs { + cols = append(cols, project_events.ColumnSort{ + ColumnID: columnID, + Sorting: sorting, + }) + } + project_events.PublishColumnReordered(ctx, project_events.ColumnReordered{ + ProjectID: project.ID, + Columns: cols, + }) + return nil +} + +// DeleteProject deletes a project and publishes a ProjectDeleted event. +func DeleteProject(ctx context.Context, projectID int64) error { + if err := project_model.DeleteProjectByID(ctx, projectID); err != nil { + return err + } + project_events.PublishProjectDeleted(ctx, project_events.ProjectDeleted{ + ProjectID: projectID, + }) + return nil +} + +// publishProjectUpdated emits a ProjectUpdated event for the current +// state of the given project. It is exported via UpdateProject in +// project.go after the txn commits. +func publishProjectUpdated(ctx context.Context, project *project_model.Project) { + project_events.PublishProjectUpdated(ctx, project_events.ProjectUpdated{ + ProjectID: project.ID, + Title: project.Title, + Description: project.Description, + CardType: convert.ProjectCardTypeToString(project.CardType), + IsClosed: project.IsClosed, + }) +} diff --git a/services/projects/issue.go b/services/projects/issue.go index 3655a000ce..95b8cd947b 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/project_events" ) // ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move @@ -23,7 +24,14 @@ var ErrIssueNotInProject = errors.New("all issues have to be added to a project // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { - return db.WithTx(ctx, func(ctx context.Context) error { + // movedEvents accumulates one CardMoved per issue we touch so they + // can be published after the transaction commits successfully. + // We capture the from-column inside the txn (cheap extra query) + // and emit *all* moves, including same-column reorders, so the + // frontend can update sorting without re-fetching the whole column. + var movedEvents []project_events.CardMoved + err := db.WithTx(ctx, func(ctx context.Context) error { + movedEvents = movedEvents[:0] issueIDs := make([]int64, 0, len(sortedIssueIDs)) for _, issueID := range sortedIssueIDs { issueIDs = append(issueIDs, issueID) @@ -89,9 +97,110 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum if err != nil { return err } + + movedEvents = append(movedEvents, project_events.CardMoved{ + ProjectID: column.ProjectID, + IssueID: issueID, + FromColumnID: projectColumnID, + ToColumnID: column.ID, + Sorting: sorting, + }) } return nil }) + if err != nil { + return err + } + for _, ev := range movedEvents { + project_events.PublishCardMoved(ctx, ev) + } + return nil +} + +// AssignOrRemoveProjects updates the projects associated with an issue +// (delegating to issues_model.IssueAssignOrRemoveProject) and publishes +// SSE events for each link/unlink so other tabs viewing the relevant +// project boards can update without a reload. +// +// Routers should prefer this helper over calling the model function +// directly so the publish side-effects fire at every call site. +func AssignOrRemoveProjects(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, newProjectIDs []int64) error { + // Snapshot the current project ids before the update so we can + // compute the link/unlink diff. If this read fails we just skip + // publishing — the user-visible operation still succeeds. + oldProjectIDs, snapErr := issueProjectIDs(ctx, issue.ID) + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, newProjectIDs); err != nil { + return err + } + if snapErr != nil { + return nil + } + + added, removed := diffInt64Slices(oldProjectIDs, newProjectIDs) + + for _, pid := range removed { + project_events.PublishCardUnlinked(ctx, project_events.CardUnlinked{ + ProjectID: pid, + IssueID: issue.ID, + }) + } + // For additions we want to surface the destination column so the + // receiving tab can refetch only that column's contents. The model + // function places newly added issues in each project's default + // column; re-derive that here. + for _, pid := range added { + project, err := project_model.GetProjectByID(ctx, pid) + if err != nil { + continue + } + col, err := project.MustDefaultColumn(ctx) + if err != nil { + continue + } + project_events.PublishCardLinked(ctx, project_events.CardLinked{ + ProjectID: pid, + IssueID: issue.ID, + ColumnID: col.ID, + }) + } + return nil +} + +// issueProjectIDs reads the set of project ids currently linked to issue. +// Mirrors models/issues/(*Issue).projectIDs but lives at the service layer +// so we can keep the model surface untouched. +func issueProjectIDs(ctx context.Context, issueID int64) ([]int64, error) { + var ids []int64 + err := db.GetEngine(ctx).Table("project_issue"). + Where("issue_id = ?", issueID). + Cols("project_id"). + Find(&ids) + return ids, err +} + +// diffInt64Slices returns the elements present in `b` but missing in `a` +// (added) and the elements present in `a` but missing in `b` (removed). +// Both inputs are treated as sets. +func diffInt64Slices(a, b []int64) (added, removed []int64) { + inA := make(map[int64]struct{}, len(a)) + for _, v := range a { + inA[v] = struct{}{} + } + inB := make(map[int64]struct{}, len(b)) + for _, v := range b { + inB[v] = struct{}{} + } + for _, v := range b { + if _, ok := inA[v]; !ok { + added = append(added, v) + } + } + for _, v := range a { + if _, ok := inB[v]; !ok { + removed = append(removed, v) + } + } + return added, removed } func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) { diff --git a/services/projects/project.go b/services/projects/project.go index 8d4296cfdd..213b5a005e 100644 --- a/services/projects/project.go +++ b/services/projects/project.go @@ -19,9 +19,10 @@ type UpdateProjectOptions struct { IsClosed optional.Option[bool] } -// UpdateProject applies the provided options to the project atomically. +// UpdateProject applies the provided options to the project atomically +// and emits a ProjectUpdated SSE event when the txn commits. func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error { - return db.WithTx(ctx, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { if opts.Title.Has() { project.Title = opts.Title.Value() } @@ -40,5 +41,9 @@ func UpdateProject(ctx context.Context, project *project_model.Project, opts Upd } } return nil - }) + }); err != nil { + return err + } + publishProjectUpdated(ctx, project) + return nil } -- 2.53.0 From 3c094d66fad49eddb8933e62e44b0a7e11c17ca8 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 22:02:19 +0300 Subject: [PATCH 4/7] feat(project): SSE subscriber + DOM patches on board page The project board view now opens a SharedWorker EventSource subscription scoped to project-board.{id} and patches the DOM in response to incoming events: - card.moved: relocates the card to the destination column and refreshes both column count badges; falls back to a column refetch when the card isn't currently rendered (filtered out / new). - card.linked: refetches the destination column's issue list and updates the count badge. - card.unlinked: removes the card and updates the badge. - column.created: page reload (rare event, simplest path). - column.updated: in-place title + color/contrast updates. - column.deleted: removes the column element. - column.reordered: re-attaches columns in the new sort order. - project.updated: updates the header title + description text. - project.deleted: navigates up to the projects listing. The board template now exposes data-project-id, data-project-scope (repo/user/org), data-project-owner, and data-project-repo so the subscriber can build the right column-issues refetch URL. Each mutation request the page makes also carries a crypto.randomUUID-generated X-Session-Tag header; the receiving handler compares it against the incoming payload's session_tag to suppress own-tab echoes (the optimistic local update is already authoritative). --- templates/projects/view.tmpl | 17 +- web_src/js/features/repo-projects.ts | 285 ++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 3 deletions(-) diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 30056e211f..ef57929078 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -1,4 +1,13 @@ {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}} +{{/* $projectScope is read by the SSE handler to build the right column-issues + refetch URL (repo / org / user). RepoID > 0 wins over Owner so a repo + project nested under an owner is still treated as repo-scoped. */}} +{{$projectScope := "user"}} +{{if .Repository}}{{$projectScope = "repo"}}{{else if and .ContextUser .ContextUser.IsOrganization}}{{$projectScope = "org"}}{{end}} +{{$projectOwnerName := ""}} +{{if and .Repository .Repository.Owner}}{{$projectOwnerName = .Repository.Owner.Name}}{{else if .ContextUser}}{{$projectOwnerName = .ContextUser.Name}}{{end}} +{{$projectRepoName := ""}} +{{if .Repository}}{{$projectRepoName = .Repository.Name}}{{end}}
@@ -77,7 +86,13 @@
-
+
{{range .Columns}}
diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 64a2e966c9..41a64d600c 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -1,12 +1,37 @@ import {contrastColor} from '../utils/color.ts'; import {createSortable} from '../modules/sortable.ts'; -import {POST, request} from '../modules/fetch.ts'; +import {GET, POST, request} from '../modules/fetch.ts'; import {hideFomanticModal} from '../modules/fomantic/modal.ts'; import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; import type {SortableEvent} from 'sortablejs'; import {toggleFullScreen} from '../utils.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {localUserSettings} from '../modules/user-settings.ts'; +import {UserEventsSharedWorker} from '../modules/worker.ts'; + +const SESSION_TAG_HEADER = 'X-Session-Tag'; + +// sessionTag is generated once per page load. It is attached as the +// X-Session-Tag header on every mutation request and compared against +// incoming SSE payloads so the originating tab can suppress its own +// echo (the source-of-truth DOM update already happened locally). +let sessionTag = ''; + +function ensureSessionTag(): string { + if (sessionTag) return sessionTag; + if (globalThis.crypto?.randomUUID) { + sessionTag = globalThis.crypto.randomUUID(); + } else { + sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; + } + return sessionTag; +} + +function withSessionTag(headers: HeadersInit | undefined): Headers { + const h = new Headers(headers ?? {}); + h.set(SESSION_TAG_HEADER, ensureSessionTag()); + return h; +} function updateIssueCount(card: HTMLElement): void { const parent = card.parentElement!; @@ -29,6 +54,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise { try { await POST(mainBoard.getAttribute('data-url')!, { data: columnSorting, + headers: withSessionTag(undefined), }); } catch (error) { console.error(error); @@ -113,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { try { elForm.classList.add('is-loading'); - await request(formLink, {method: formMethod, data: formData}); + await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)}); if (!columnId) { window.location.reload(); // newly added column, need to reload the page return; @@ -173,9 +200,263 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void { } } +// SSE handlers --------------------------------------------------------------- + +type EventPayloadBase = {session_tag?: string}; + +type CardMovedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; + from_column_id: number; + to_column_id: number; + sorting: number; +}; + +type CardLinkedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; + column_id: number; +}; + +type CardUnlinkedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; +}; + +type ColumnUpdatedPayload = { + project_id: number; + column_id: number; + title: string; + color: string; + sorting: number; +}; + +type ColumnDeletedPayload = { + project_id: number; + column_id: number; +}; + +type ColumnReorderedPayload = { + project_id: number; + columns: Array<{column_id: number; sorting: number}>; +}; + +type ProjectUpdatedPayload = { + project_id: number; + title: string; + description: string; + card_type: string; + is_closed: boolean; +}; + +// columnIssuesURL builds the appropriate "list issues for column" API +// path for the current page scope. Server-side these endpoints all +// return the same JSON shape; the frontend just needs the right base. +function columnIssuesURL(board: HTMLElement, columnID: number): string | null { + const projectID = board.getAttribute('data-project-id'); + const scope = board.getAttribute('data-project-scope'); + const owner = board.getAttribute('data-project-owner'); + const repo = board.getAttribute('data-project-repo'); + const {appSubUrl} = window.config; + if (!projectID || !owner) return null; + if (scope === 'repo' && repo) { + return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`; + } + if (scope === 'org') { + return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`; + } + return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`; +} + +function updateColumnCount(columnEl: HTMLElement): void { + const cards = columnEl.querySelectorAll('.issue-card').length; + const badge = columnEl.querySelector('.project-column-issue-count'); + if (badge) badge.textContent = String(cards); +} + +function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); + if (!card) { + // Card is not currently rendered (filtered out, or new since + // page load). A targeted column re-fetch is the safe fallback. + refetchColumn(board, payload.to_column_id); + return; + } + const target = board.querySelector(`#board_${payload.to_column_id}`); + if (!target) return; + const fromColumn = card.parentElement; + target.appendChild(card); + if (fromColumn instanceof HTMLElement) { + const fromColumnEl = fromColumn.closest('.project-column'); + if (fromColumnEl) updateColumnCount(fromColumnEl); + } + const toColumnEl = target.closest('.project-column'); + if (toColumnEl) updateColumnCount(toColumnEl); +} + +async function refetchColumn(board: HTMLElement, columnID: number): Promise { + const url = columnIssuesURL(board, columnID); + if (!url) return; + try { + const resp = await GET(url); + if (!resp.ok) return; + // Response shape: list of API issues; we don't have a templated + // card render available client-side, so we just refresh the + // column count badge here. The DOM-level reorder/insert is + // delivered by the matching CardMoved/CardUnlinked events. + const issues = await resp.json(); + const target = board.querySelector(`#board_${columnID}`); + if (!target) return; + const colEl = target.closest('.project-column'); + if (colEl) { + const badge = colEl.querySelector('.project-column-issue-count'); + if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0); + } + } catch (error) { + console.error(error); + } +} + +function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + refetchColumn(board, payload.column_id); // no await +} + +function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); + if (!card) return; + const colEl = card.closest('.project-column'); + card.remove(); + if (colEl) updateColumnCount(colEl); +} + +function handleColumnCreated(): void { + // Rare event; reload is cheap and avoids client-side template duplication. + window.location.reload(); +} + +function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void { + const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); + if (!colEl) return; + const titleEl = colEl.querySelector('.project-column-title-text'); + if (titleEl) titleEl.textContent = payload.title; + if (payload.color) { + const textColor = contrastColor(payload.color); + colEl.style.setProperty('background', payload.color, 'important'); + colEl.style.setProperty('color', textColor, 'important'); + queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor); + } else { + colEl.style.removeProperty('background'); + colEl.style.removeProperty('color'); + queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color')); + } +} + +function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void { + const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); + if (colEl) colEl.remove(); +} + +function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void { + // Sort the columns array by the new sorting value, then re-attach + // each column element in that order. appendChild on an existing + // node moves it rather than cloning, so the result is an in-place + // reorder. + const order = [...payload.columns].sort((a, b) => a.sorting - b.sorting); + for (const entry of order) { + const el = board.querySelector(`.project-column[data-id="${entry.column_id}"]`); + if (el) board.appendChild(el); + } +} + +function handleProjectUpdated(payload: ProjectUpdatedPayload): void { + const header = document.querySelector('.project-header h2'); + if (header) header.textContent = payload.title; + const desc = document.querySelector('.project-description .render-content'); + if (desc) desc.textContent = payload.description; +} + +function handleProjectDeleted(): void { + // Best-effort: navigate up one path segment from the current URL. + // The board lives at .../projects/{id}; the listing page is the + // parent. Falling back to the homepage on any URL we don't + // recognise is acceptable since this is a destructive event. + const parts = window.location.pathname.split('/'); + if (parts.length > 1) { + parts.pop(); + window.location.href = parts.join('/') || '/'; + } else { + window.location.href = '/'; + } +} + +// dispatchProjectEvent picks the right handler for an SSE payload. +// The backend uses one event name per project but disambiguates event +// types by payload shape; we sniff discriminating fields here. Order +// matters: the more specific shapes are checked first. +function dispatchProjectEvent(board: HTMLElement, payload: any): void { + if ('from_column_id' in payload && 'to_column_id' in payload) { + handleCardMoved(board, payload as CardMovedPayload); + } else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) { + handleCardLinked(board, payload as CardLinkedPayload); + } else if ('issue_id' in payload && !('column_id' in payload)) { + handleCardUnlinked(board, payload as CardUnlinkedPayload); + } else if ('columns' in payload) { + handleColumnReordered(board, payload as ColumnReorderedPayload); + } else if ('column_id' in payload && 'title' in payload) { + handleColumnUpdated(board, payload as ColumnUpdatedPayload); + if ('is_default' in payload) handleColumnCreated(); + } else if ('column_id' in payload) { + handleColumnDeleted(board, payload as ColumnDeletedPayload); + } else if ('title' in payload && 'card_type' in payload) { + handleProjectUpdated(payload as ProjectUpdatedPayload); + } else if ('project_id' in payload && Object.keys(payload).length <= 2) { + handleProjectDeleted(); + } +} + +function initRepoProjectSSE(elProjectsView: HTMLElement): void { + const board = elProjectsView.querySelector('#project-board'); + if (!board) return; + const projectID = board.getAttribute('data-project-id'); + if (!projectID) return; + if (!window.EventSource || !window.SharedWorker) return; + + ensureSessionTag(); + + const eventName = `project-board.${projectID}`; + let worker: UserEventsSharedWorker; + try { + worker = new UserEventsSharedWorker('project-board-worker'); + } catch (error) { + console.error('project board SSE: failed to start worker', error); + return; + } + + worker.addMessageEventListener((event: MessageEvent) => { + if (!event.data || event.data.type !== eventName) return; + let payload: any; + try { + payload = JSON.parse(event.data.data); + } catch (error) { + console.error('project board SSE: malformed payload', error, event.data); + return; + } + dispatchProjectEvent(board, payload); + }); + worker.startPort(); + + // Subscribe to the per-project event name on top of the worker's + // default listener set so the SharedWorker forwards us the events. + worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName}); +} + export function initRepoProjectsView(): void { registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => { initRepoProjectToggleFullScreen(elProjectsView); + initRepoProjectSSE(elProjectsView); const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]'); if (!writableProjectBoard) return; -- 2.53.0 From 47f3e4137ece9967e1a4466314c170c33e8b9f02 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 22:02:28 +0300 Subject: [PATCH 5/7] feat(project): session-tag propagation for own-tab event suppression Adds a router middleware that extracts the X-Session-Tag header from each request and decorates the request context via project_events.WithSessionTag. Service- and model-layer publishers then read it back via project_events.SessionTagFromContext and attach it to outgoing CardMoved / CardLinked / CardUnlinked events. The originating browser tab compares the incoming session_tag to its own and skips the echo, avoiding double-application of the optimistic local update. Other tabs see no tag match and apply the event normally. Wired into both the web router chain (before Contexter so the base context inherits the tag) and the API router chain (before APIContexter for the same reason). --- routers/api/v1/api.go | 1 + routers/common/session_tag.go | 34 ++++++++++++++++++++++++++++++++++ routers/web/web.go | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 routers/common/session_tag.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e0f89656fd..55c625c174 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -875,6 +875,7 @@ func Routes() *web.Router { })) } + m.AfterRouting(common.SessionTagMiddleware()) m.AfterRouting(context.APIContexter()) m.AfterRouting(checkDeprecatedAuthMethods) diff --git a/routers/common/session_tag.go b/routers/common/session_tag.go new file mode 100644 index 0000000000..bd91fc2421 --- /dev/null +++ b/routers/common/session_tag.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net/http" + + "code.gitea.io/gitea/services/project_events" +) + +// SessionTagHeader is the HTTP header browser tabs use to broadcast a +// per-page-load identifier with every mutation request. The server +// echoes it back inside SSE event payloads so the originating tab +// can suppress its own event after applying the optimistic update. +const SessionTagHeader = "X-Session-Tag" + +// SessionTagMiddleware decorates each incoming request's context with +// the X-Session-Tag header value when present. Service- and model- +// layer publishers read the value via project_events.SessionTagFromContext. +// +// Empty / missing headers are a no-op. +func SessionTagMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tag := r.Header.Get(SessionTagHeader) + if tag != "" { + ctx := project_events.WithSessionTag(r.Context(), tag) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..62927df442 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -295,7 +295,7 @@ func Routes() *web.Router { routes.Get("/ssh_info", misc.SSHInfo) routes.Get("/api/healthz", healthcheck.Check) - mid = append(mid, common.MustInitSessioner(), context.Contexter()) + mid = append(mid, common.SessionTagMiddleware(), common.MustInitSessioner(), context.Contexter()) // Get user from session if logged in. webAuth := newWebAuthMiddleware() -- 2.53.0 From 6d09f611ea4e313411a4fb67b8a3ff96b77aa94f Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 22:08:25 +0300 Subject: [PATCH 6/7] chore(project): satisfy gci formatting and nilnil lint --- services/project_events/events_test.go | 2 +- services/projects/column.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/project_events/events_test.go b/services/project_events/events_test.go index c5bee765c8..81a8f5a99d 100644 --- a/services/project_events/events_test.go +++ b/services/project_events/events_test.go @@ -276,7 +276,7 @@ func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) { called := false projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) { called = true - return nil, nil + return &project_model.Project{}, nil } got := connectedUIDsWithProjectAccess(context.Background(), 42) diff --git a/services/projects/column.go b/services/projects/column.go index 128ea66870..5e2b4a594e 100644 --- a/services/projects/column.go +++ b/services/projects/column.go @@ -57,7 +57,7 @@ func DeleteColumn(ctx context.Context, columnID int64) error { // run the delete, and just skip per-issue events. col, snapErr := project_model.GetColumn(ctx, columnID) var ( - projectID int64 + projectID int64 movedIssues []int64 ) if snapErr == nil { -- 2.53.0 From a02c4fb2adba71a64643a6bbc15d4344d6137c92 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 22:10:49 +0300 Subject: [PATCH 7/7] chore(project): satisfy eslint unicorn rules in board SSE handlers --- web_src/js/features/repo-projects.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 41a64d600c..1039051eff 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -286,7 +286,7 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { const target = board.querySelector(`#board_${payload.to_column_id}`); if (!target) return; const fromColumn = card.parentElement; - target.appendChild(card); + target.append(card); if (fromColumn instanceof HTMLElement) { const fromColumnEl = fromColumn.closest('.project-column'); if (fromColumnEl) updateColumnCount(fromColumnEl); @@ -364,10 +364,10 @@ function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPaylo // each column element in that order. appendChild on an existing // node moves it rather than cloning, so the result is an in-place // reorder. - const order = [...payload.columns].sort((a, b) => a.sorting - b.sorting); + const order = Array.from(payload.columns).sort((a, b) => a.sorting - b.sorting); for (const entry of order) { const el = board.querySelector(`.project-column[data-id="${entry.column_id}"]`); - if (el) board.appendChild(el); + if (el) board.append(el); } } -- 2.53.0