feat(project): SSE push updates for project board pages (#7)
This commit was merged in pull request #7.
This commit is contained in:
@@ -68,6 +68,19 @@ func (m *Manager) UnregisterAll() {
|
|||||||
m.messengers = map[int64]*Messenger{}
|
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
|
// SendMessage sends a message to a particular user
|
||||||
func (m *Manager) SendMessage(uid int64, message *Event) {
|
func (m *Manager) SendMessage(uid int64, message *Event) {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ func Routes() *web.Router {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.AfterRouting(common.SessionTagMiddleware())
|
||||||
m.AfterRouting(context.APIContexter())
|
m.AfterRouting(context.APIContexter())
|
||||||
m.AfterRouting(checkDeprecatedAuthMethods)
|
m.AfterRouting(checkDeprecatedAuthMethods)
|
||||||
|
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func DeleteOrgProject(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -468,7 +468,7 @@ func CreateOrgProjectColumn(ctx *context.APIContext) {
|
|||||||
CreatorID: ctx.Doer.ID,
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ func EditOrgProjectColumn(ctx *context.APIContext) {
|
|||||||
column.Sorting = int8(*form.Sorting)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ func DeleteOrgProjectColumn(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -811,7 +811,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs = append(newProjectIDs, id)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -821,7 +821,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
||||||
copy(newProjectIDs, currentProjectIDs)
|
copy(newProjectIDs, currentProjectIDs)
|
||||||
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
"code.gitea.io/gitea/services/convert"
|
"code.gitea.io/gitea/services/convert"
|
||||||
issue_service "code.gitea.io/gitea/services/issue"
|
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.
|
// 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
|
// Update projects if provided
|
||||||
if canWrite && form.Projects != nil {
|
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) {
|
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.APIError(http.StatusBadRequest, err)
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ func DeleteProject(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -505,7 +505,7 @@ func CreateProjectColumn(ctx *context.APIContext) {
|
|||||||
CreatorID: ctx.Doer.ID,
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -587,7 +587,7 @@ func EditProjectColumn(ctx *context.APIContext) {
|
|||||||
column.Sorting = int8(*form.Sorting)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -639,7 +639,7 @@ func DeleteProjectColumn(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -876,7 +876,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs = append(newProjectIDs, id)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -888,7 +888,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
||||||
copy(newProjectIDs, currentProjectIDs)
|
copy(newProjectIDs, currentProjectIDs)
|
||||||
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ func DeleteUserProject(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
if err := project_service.DeleteProject(ctx, project.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -494,7 +494,7 @@ func CreateUserProjectColumn(ctx *context.APIContext) {
|
|||||||
CreatorID: ctx.Doer.ID,
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ func EditUserProjectColumn(ctx *context.APIContext) {
|
|||||||
column.Sorting = int8(*form.Sorting)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@ func DeleteUserProjectColumn(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
if err := project_service.DeleteColumn(ctx, column.ID); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -847,7 +847,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs = append(newProjectIDs, id)
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -857,7 +857,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
|
|||||||
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
newProjectIDs := make([]int64, len(currentProjectIDs)+1)
|
||||||
copy(newProjectIDs, currentProjectIDs)
|
copy(newProjectIDs, currentProjectIDs)
|
||||||
newProjectIDs[len(currentProjectIDs)] = column.ProjectID
|
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)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,7 +214,7 @@ func DeleteProject(ctx *context.Context) {
|
|||||||
return
|
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())
|
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
||||||
@@ -283,7 +283,7 @@ func EditProjectPost(ctx *context.Context) {
|
|||||||
p.Title = form.Title
|
p.Title = form.Title
|
||||||
p.Description = form.Content
|
p.Description = form.Content
|
||||||
p.CardType = form.CardType
|
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)
|
ctx.ServerError("UpdateProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -496,7 +496,7 @@ func DeleteProjectColumn(ctx *context.Context) {
|
|||||||
return
|
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)
|
ctx.ServerError("DeleteProjectColumnByID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -514,7 +514,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.NewColumn(ctx, &project_model.Column{
|
if err := project_service.CreateColumn(ctx, &project_model.Column{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Title: form.Title,
|
Title: form.Title,
|
||||||
Color: form.Color,
|
Color: form.Color,
|
||||||
@@ -567,7 +567,7 @@ func EditProjectColumn(ctx *context.Context) {
|
|||||||
column.Sorting = form.Sorting
|
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)
|
ctx.ServerError("UpdateProjectColumn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func DeleteProject(ctx *context.Context) {
|
|||||||
return
|
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())
|
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
||||||
@@ -264,7 +264,7 @@ func EditProjectPost(ctx *context.Context) {
|
|||||||
p.Title = form.Title
|
p.Title = form.Title
|
||||||
p.Description = form.Content
|
p.Description = form.Content
|
||||||
p.CardType = form.CardType
|
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)
|
ctx.ServerError("UpdateProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@ func UpdateIssueProject(ctx *context.Context) {
|
|||||||
projectIDs = filteredIDs
|
projectIDs = filteredIDs
|
||||||
var failedIssues []int64
|
var failedIssues []int64
|
||||||
for _, issue := range issues {
|
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) {
|
if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
failedIssues = append(failedIssues, issue.ID)
|
failedIssues = append(failedIssues, issue.ID)
|
||||||
continue
|
continue
|
||||||
@@ -569,7 +569,7 @@ func DeleteProjectColumn(ctx *context.Context) {
|
|||||||
return
|
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)
|
ctx.ServerError("DeleteProjectColumnByID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -597,7 +597,7 @@ func AddColumnToProjectPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.NewColumn(ctx, &project_model.Column{
|
if err := project_service.CreateColumn(ctx, &project_model.Column{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Title: form.Title,
|
Title: form.Title,
|
||||||
Color: form.Color,
|
Color: form.Color,
|
||||||
@@ -672,7 +672,7 @@ func EditProjectColumn(ctx *context.Context) {
|
|||||||
column.Sorting = form.Sorting
|
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)
|
ctx.ServerError("UpdateProjectColumn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
project_model "code.gitea.io/gitea/models/project"
|
project_model "code.gitea.io/gitea/models/project"
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
"code.gitea.io/gitea/services/context"
|
"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
|
// 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
|
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)
|
ctx.ServerError("MoveColumnsOnProject", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -295,7 +295,7 @@ func Routes() *web.Router {
|
|||||||
routes.Get("/ssh_info", misc.SSHInfo)
|
routes.Get("/ssh_info", misc.SSHInfo)
|
||||||
routes.Get("/api/healthz", healthcheck.Check)
|
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.
|
// Get user from session if logged in.
|
||||||
webAuth := newWebAuthMiddleware()
|
webAuth := newWebAuthMiddleware()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
+110
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
|
"code.gitea.io/gitea/services/project_events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move
|
// 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
|
// 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 {
|
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))
|
issueIDs := make([]int64, 0, len(sortedIssueIDs))
|
||||||
for _, issueID := range sortedIssueIDs {
|
for _, issueID := range sortedIssueIDs {
|
||||||
issueIDs = append(issueIDs, issueID)
|
issueIDs = append(issueIDs, issueID)
|
||||||
@@ -89,9 +97,110 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
movedEvents = append(movedEvents, project_events.CardMoved{
|
||||||
|
ProjectID: column.ProjectID,
|
||||||
|
IssueID: issueID,
|
||||||
|
FromColumnID: projectColumnID,
|
||||||
|
ToColumnID: column.ID,
|
||||||
|
Sorting: sorting,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ type UpdateProjectOptions struct {
|
|||||||
IsClosed optional.Option[bool]
|
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 {
|
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() {
|
if opts.Title.Has() {
|
||||||
project.Title = opts.Title.Value()
|
project.Title = opts.Title.Value()
|
||||||
}
|
}
|
||||||
@@ -40,5 +41,9 @@ func UpdateProject(ctx context.Context, project *project_model.Project, opts Upd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
publishProjectUpdated(ctx, project)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
{{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
|
{{$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}}
|
||||||
|
|
||||||
<div class="ui container fluid padded projects-view" data-global-init="initRepoProjectsView">
|
<div class="ui container fluid padded projects-view" data-global-init="initRepoProjectsView">
|
||||||
<div class="ui container flex-text-block project-header">
|
<div class="ui container flex-text-block project-header">
|
||||||
@@ -77,7 +86,13 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}"
|
||||||
|
data-project-board-writable="{{$canWriteProject}}"
|
||||||
|
data-project-id="{{.Project.ID}}"
|
||||||
|
data-project-scope="{{$projectScope}}"
|
||||||
|
data-project-owner="{{$projectOwnerName}}"
|
||||||
|
data-project-repo="{{$projectRepoName}}"
|
||||||
|
{{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
||||||
{{range .Columns}}
|
{{range .Columns}}
|
||||||
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
||||||
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import {contrastColor} from '../utils/color.ts';
|
import {contrastColor} from '../utils/color.ts';
|
||||||
import {createSortable} from '../modules/sortable.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 {hideFomanticModal} from '../modules/fomantic/modal.ts';
|
||||||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||||
import type {SortableEvent} from 'sortablejs';
|
import type {SortableEvent} from 'sortablejs';
|
||||||
import {toggleFullScreen} from '../utils.ts';
|
import {toggleFullScreen} from '../utils.ts';
|
||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
import {localUserSettings} from '../modules/user-settings.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 {
|
function updateIssueCount(card: HTMLElement): void {
|
||||||
const parent = card.parentElement!;
|
const parent = card.parentElement!;
|
||||||
@@ -29,6 +54,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
|
|||||||
try {
|
try {
|
||||||
await POST(`${to.getAttribute('data-url')}/move`, {
|
await POST(`${to.getAttribute('data-url')}/move`, {
|
||||||
data: columnSorting,
|
data: columnSorting,
|
||||||
|
headers: withSessionTag(undefined),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -61,6 +87,7 @@ async function initRepoProjectSortable(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await POST(mainBoard.getAttribute('data-url')!, {
|
await POST(mainBoard.getAttribute('data-url')!, {
|
||||||
data: columnSorting,
|
data: columnSorting,
|
||||||
|
headers: withSessionTag(undefined),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -113,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
elForm.classList.add('is-loading');
|
elForm.classList.add('is-loading');
|
||||||
await request(formLink, {method: formMethod, data: formData});
|
await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)});
|
||||||
if (!columnId) {
|
if (!columnId) {
|
||||||
window.location.reload(); // newly added column, need to reload the page
|
window.location.reload(); // newly added column, need to reload the page
|
||||||
return;
|
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<HTMLElement>(`.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<HTMLElement>(`#board_${payload.to_column_id}`);
|
||||||
|
if (!target) return;
|
||||||
|
const fromColumn = card.parentElement;
|
||||||
|
target.append(card);
|
||||||
|
if (fromColumn instanceof HTMLElement) {
|
||||||
|
const fromColumnEl = fromColumn.closest<HTMLElement>('.project-column');
|
||||||
|
if (fromColumnEl) updateColumnCount(fromColumnEl);
|
||||||
|
}
|
||||||
|
const toColumnEl = target.closest<HTMLElement>('.project-column');
|
||||||
|
if (toColumnEl) updateColumnCount(toColumnEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
|
||||||
|
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<HTMLElement>(`#board_${columnID}`);
|
||||||
|
if (!target) return;
|
||||||
|
const colEl = target.closest<HTMLElement>('.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<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
const colEl = card.closest<HTMLElement>('.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<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||||
|
if (!colEl) return;
|
||||||
|
const titleEl = colEl.querySelector<HTMLElement>('.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<HTMLElement>(`.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<HTMLElement>(`.project-column[data-id="${entry.column_id}"]`);
|
||||||
|
if (el) board.append(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectUpdated(payload: ProjectUpdatedPayload): void {
|
||||||
|
const header = document.querySelector<HTMLElement>('.project-header h2');
|
||||||
|
if (header) header.textContent = payload.title;
|
||||||
|
const desc = document.querySelector<HTMLElement>('.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<HTMLElement>('#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 {
|
export function initRepoProjectsView(): void {
|
||||||
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
||||||
initRepoProjectToggleFullScreen(elProjectsView);
|
initRepoProjectToggleFullScreen(elProjectsView);
|
||||||
|
initRepoProjectSSE(elProjectsView);
|
||||||
|
|
||||||
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
||||||
if (!writableProjectBoard) return;
|
if (!writableProjectBoard) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user