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() 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/api/v1/org/project.go b/routers/api/v1/org/project.go index b5e3fc2c3c..03832d66cb 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 } @@ -468,7 +468,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 } @@ -545,7 +545,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 } @@ -592,7 +592,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 } @@ -811,7 +811,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 } @@ -821,7 +821,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 e3650224bb..99ec89b504 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 } @@ -505,7 +505,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 } @@ -587,7 +587,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 } @@ -639,7 +639,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 } @@ -876,7 +876,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 } @@ -888,7 +888,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 f14fc8a608..1c7a90e633 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 } @@ -494,7 +494,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 } @@ -575,7 +575,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 } @@ -626,7 +626,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 } @@ -847,7 +847,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 } @@ -857,7 +857,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/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/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/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() 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..81a8f5a99d --- /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 &project_model.Project{}, 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) +} diff --git a/services/projects/column.go b/services/projects/column.go new file mode 100644 index 0000000000..5e2b4a594e --- /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 } 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..1039051eff 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.append(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 = 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.append(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;